Главная страница   /   9.1. Обзор языков (Метапрограммирование в .NET

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

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

Кевин Хазард

9.1. Обзор языков

В этом разделе вы увидите, как метапрограммирование работает в двух языках – Boo и Nemerle, но сначала мы вернемся к C#. Вы вернетесь к идее кода как данных, которую вы видели в главе 6, и посмотрите, как это работает в C#. С помощью этого маленького напоминания выражений вы будете действительно понимать, как эти другие языки поднимают метапрограммирование из API выражений в первоклассную возможность языка.

C# и ограничения для выражений

В главе 6 вы видели, как Expression API позволяет управлять кодированием во время выполнения. Рассмотрим, например, следующее выражение

Expression<Func<int, int, int>> add = (x, y) => x + y;
var result = add.Compile()(2, 3);

Вы знаете, что переменная add ссылается на дерево выражений. Это дерево состоит из BinaryExpression для операции add и двух узлов ParameterExpression, которые представляют параметры х и у выражению. Дерево выражений не является исполняемым в его нынешнем виде, вы должны скомпилировать его, чтобы создать метод, который можно вызвать.

Основная часть контента в главе 6 вращается вокруг выражений c точки зрения API. Вы не создавали выражения при помощи встроенного подхода кодирования, вы формулировали выражения с помощью статических методов класса Expression. Хотя это работает, но ведь было бы классно, если бы C# обрабатывал выражения в самом языке? Например, давайте посмотрим на фиктивный кусочек C# кода, который содержит символ, не существующий в C#, но представьте себе на секунду, если бы ` работал вот так:

var add = `(x, y) => x + y;

Обратите внимание, что это не одинарные кавычки, используемые для определения значений символов в C#. Это «обратная галочка» (backtick). В любом случае, это не имеет значения, потому что в C# она не работает. Дело вот в чем: прямо сейчас вы не можете объявить выражение и присвоить его типу переменной через интерфейс в C#. Вы должны явно указать тип переменной, чтобы C# знал, что вы хотите выражение, а не лямбду. Но чтобы получить выражение, нужно изрядно потрудиться. Наличие одного символа сильно облегчит генерацию выражений в C#.

Вот еще одно ограничение выражений. Рассмотрим этот кусок C# кода, который является невалидным и не будет компилироваться:

Expression<Func<TextWriter, int>> a = (writer) =>
{
	var x = new Random().Next();
	writer.WriteLine(x);
	var q = 11 + x;
	writer.WriteLine(q);
	return q;
};

Почему он не скомпилируется? C# не поддерживает многострочных выражений. Можно создавать сложные выражения с помощью Expression API, но вы не можете сделать этого как объявленное выражение в C# коде. Должна быть одна строка кода.

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

Если честно, создатели и персонал поддержки C# получают много просьб и предложений от разработчиков, чтобы добавить разные виды возможностей в язык, так что понятно, что не все можно сделать, чего хочется. Но другие .Net языки имеют встроенные возможности создания выражений непосредственно в их родном функционале. Данные языки делают это почти тривиальным – придумывать функции, которыми можно манипулировать во время выполнения, а в некоторых случаях позволяют вам переопределить сам язык. Давайте начнем наше путешествие по языкам с Boo.

Boo и метапрограммирование

Первый язык, который мы рассмотрим, называется Boo. Если вы когда-либо работали (или хотя бы видели) с Python, вы будете чувствовать себя, как дома, с Boo. Boo имеет довольно много возможностей метапрограммирования, которые вы можете использовать естественным образом в пределах самого языка. Давайте начнем с простого фрагмента кода Boo, чтобы вы могли почувствовать структуру языка.

Простой класс в Boo

Хотя Boo не является строгой производной от Python, между ними есть изрядное сходство. Вот простое определение класса в Boo:

import System
class Data:
	def constructor():
		pass
	def constructor(value as Guid):
		_value = value
	[Getter(Value)]
		_value as Guid
[STAThread]
def Main(args as (string)):
	print(Data().Value)
	print(Data(Guid.NewGuid()).Value)

Как и в Python, пробел является ключом для определения и области видимости кода в Boo. Вот почему методы отделены отступом от определения класса, а реализация метода отделена отступом от определения метода. Это не займет много времени – прочесть Boo код и понять, что он делает. Вы знаете, что есть класс Data с двумя конструкторами. У вас также есть метод Main(), где вы выводите информацию в окно консоли. Создание объекта в Boo не требует ключевого слова new, в отличие от C#, вы просто добавляете к имени класса скобки, и все готово.

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

Это не совсем верно, что вы обязаны использовать атрибуты для определения свойств. Следующий код Boo определяет свойство Value, которое работает так же, как свойство Value в классе Data:

Value as Guid:
	get:
		return _value
_value as Guid

Класс GetterAttribute является особым видом атрибута, который обрабатывается совсем по-другому, чем другие атрибуты в Boo. Это своего рода AbstractAstAttribute, который меняет код, когда он скомпилирован. Такая мощь может привести к отличным манипуляциям с кодом, но прежде чем погрузиться в эти специальные атрибуты, давайте посмотрим, как вы можете определить куски кода Boo непосредственно в формате AST.

Понимание литералов кода в Boo

Одна из замечательных вещей Boo заключается в том, что позволяет определять функции как деревья, как вы можете делать в C# при помощи Expression API. Но хотя Boo имеет свой собственный API, который похож на LINQ Expression API, вы не ограничены прохождением через API вызовы для создания дерева. Давайте начнем с простого метода сложения в Boo:

def Add(x as int, y as int):
	return x + y

Если бы вы представили это в виде древовидной структуры Boo AST API, она выглядела бы примерно как код в следующем листинге.

Листинг 9-1: Представление метода Boo при помощи AST API
import Boo.Lang.Compiler.Ast
xParameter = ParameterDeclaration(
	Name: 'x',
	Type: SimpleTypeReference(Name: 'int'))
yParameter = ParameterDeclaration(
	Name: 'y',
	Type: SimpleTypeReference(Name: 'int'))
parameters = ParameterDeclarationCollection()
parameters.Add(xParameter)
parameters.Add(yParameter)
apiAdd = Method(
	Name: 'LiteralAdd',
	Parameters: parameters,
	Body: Block(ReturnStatement(BinaryExpression(
		Left: Expression.Lift(xParameter),
		Right: Expression.Lift(yParameter),
		Operator: BinaryOperatorType.Addition))))

Это кажется созданием LINQ выражения. Вы создаете параметры при помощи класса ParameterDeclaration, указывая их имена и типы. Затем вы добавляете их в Method, который также указывает имя метода. Класс Block содержит реализацию метода, представляющую собой BinaryExpression, где складываются два параметра.

А вот интересная часть:

literalAdd = [|
	def QuotedAdd(x as int, y as int):
		return x + y
|]

Это то же самое, что и код в листинге 9-1! Boo определяет маркеры квази-цитирования, [| и |], чтобы определить литералы в коде без необходимости «бегать» через API. Вы выражаете ваши намерения так же естественно, как и при создании любого другого куска кода Boo, кроме того, что в данном случае это не исполняемый код – это код в формате дерева.

Теперь, когда у вас есть дерево выражений, вы захотите иметь возможность выполнить его. В следующем разделе вы увидите, как можно скомпилировать код Boo.

Компиляция Boo кода во время выполнения

Для компиляции фрагментов Boo кода можно использовать метод compile() из сборки Boo.Lang.Compiler.MetaProgramming. Вот как это выглядит:

import Boo.Lang.Compiler.MetaProgramming
literalAdd = [|
	class QA:
		static def QuotedAdd(x as int, y as int):
			return x + y
|]
compiledLiteralAdd as duck = compile(literalAdd)
print(compiledLiteralAdd.QuotedAdd(5, 6))

Этот код – слегка измененная версия фрагмента literalAdd из последнего раздела. Он был переопределен как static для класса QA. Теперь при использовании метода compile() для фрагмента вы присваиваете его переменной с помощью ключевого слова duck. Boo поддерживает «утиную типизацию», так что решение о вызове метода принимается во время выполнения. Поэтому пока в классе есть метод QuotedAdd, решение о вызове метода будет правильным.

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

import Boo.Lang.Compiler.MetaProgramming
import Boo.Lang.Parser
stringifiedAdd = """
class SA:
	static def stringifiedAdd(x as int, y as int):
		return x + y
"""
compiledStringifiedAdd as duck = compile(BooParser.ParseString(
	'SA', stringifiedAdd)).GetType('SA')
print(compiledStringifiedAdd.stringifiedAdd(11, 22))

Три двойные кавычки позволяют охватить строку в несколько строк кода – вы получите тот же результат в C#, если вы вставите символ @ в начало объявления строки. Внутри строки stringifiedAdd можно определить класс с методом. Этот код компилируется при помощи метода ParseString() для BooParser. Этот метод возвращает объект CompileUnit, который вы можете передать методу compile() из предыдущего раздела. В этом случае вы получите стандартный объект System.Reflection.Assembly из вызова compile(), так что простой вызов GetType() - это все, что нужно, чтобы провести «утиную типизацию» возвращаемого значения, так чтобы работал ваш вызов stringifiedAdd().

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

Внедрение Boo кода при компиляции с помощью AST атрибутов

В подразделе "Простой класс в Boo" вы видели GetterAttribute. Это класс, базовым классом которого является AbstractAstAttribute. Технически это не .NET атрибут, к которым вы привыкли, как и STAThreadAttribute, используемый для метода Main() из примера кода. Они не компилируются в сборку как метаданные. Скорее, эти AST атрибуты обнаруживаются компилятором Boo и внедряют код в результирующую сборку. Таким образом, вы можете контролировать форму и поведение кода, вставив части генерации кода многоразового использования в атрибуты AST. Давайте создадим простой атрибут трассировки метода, чтобы посмотреть, как легко можно добавить код в приложение. Следующий листинг показывает, как этот атрибут выглядит.

Листинг 9-2: Создание атрибута трассировки в Boo
import Boo.Lang.Compiler
import Boo.Lang.Compiler.Ast
import System
class TraceAttribute(AbstractAstAttribute):
	def Apply(type as Node):
		target = type as ClassDefinition
		if target is null:
			raise ArgumentException(
				"TraceAttribute can only be applied to classes.",
				"type")
		for member in target.Members:
			method = member as Method
			continue if method is null
			method.Body = [|
				Console.Out.WriteLine(string.Format(
					"Method {0} started.", $(method.FullName)))
				$(method.Body)
					Console.Out.WriteLine(string.Format(
					"Method {0} finished.", $(method.FullName)))
			|]

Цель этого атрибута заключается в добавлении трассировки к каждому методу в классе. Поэтому, первое, что вам нужно сделать, это убедиться, что атрибут связан с классом. Метод Apply() вызывается компилятором Boo, а type является узлом AST, с которым связан атрибут. Вот почему type преобразован как объект ClassDefinition; если это не сработает, Apply() выбросит ArgumentException. Если TraceAttribute связан с классом, необходимо пройти интерацией по каждому методу в классе. Вот где просто «сверкают» возможности метапрограммирования Boo. Когда вы находите метод, вы преобразуете его реализацию путем изменения свойства Body. Вы внедряете два вызова Console до и после первоначального определения тела (вы определенно не можете потерять этот исходный код!). Вы используете оператор сращивания (знак доллара, $), чтобы внедрить выражение в дерево выражений, например, имя метода и тело.

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

import System
[Trace]
class TracedClass:
	def TraceMe():
		Console.Out.WriteLine("I should have been traced.")
TracedClass().TraceMe()

На рисунке 9-1 показано, что случится, если вы запустите этот код.

Рисунок 9-1: Трассировка метода в Boo. Если переместить логику трассировки в атрибут, который внедряется в метод во время компиляции, код становится годным для многоразового использования и его легче читать.

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

Мы завершим наше обсуждение метапрограммирования в Boo беглым взглядом на макросы.

Макросы в Boo

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

import Boo.Lang.Compiler
import Boo.Lang.Compiler.Ast
import System
macro power:
	yield [| Console.Out.WriteLine(
		Math.Pow($(power.Arguments[0]), $(power.Arguments[1]))) |]

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

[STAThread]
def Main(args as (string)):
	power 3.2, 4.2

Если вы используете декомпилятор, как ILSpy, вы можете увидеть, что произойдет в методеMain() (в формате C#):

[STAThread]
public static void Main(string[] args)
{
	Console.Out.WriteLine(Math.Pow(3.2, 4.2));
}

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

Этот пример достаточно прост: на самом деле, вы можете удивиться, почему вы не вызываете Math.Pow() напрямую. Причина использования макросов заключается в том, чтобы обеспечить способ добавления ключевых слов в Boo, и таким образом добавить простоту в структуру кода. Вот что делает макрос using в Boo:

using file=File.OpenText(name):
	print(file.ReadLine())

Конструкции генерации кода, таких как выражение using в C#, работают внутренне – у вас нет никакого способа предоставить код в процессе компиляции. С макросами вы можете добавить ваши реализации в Boo программы, когда вы считаете нужным. В данном случае макрос using генерирует весь код, чтобы убедиться, что объект, ссылающийся на файл, правильно удаляется в блоке finally.

Теперь, когда вы увидели, что можно сделать в Boo, давайте взглянем на Nemerle, C#-подобный язык, который имеет аналогичные конструкции метапрограммирования.

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

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

Макросы в Nemerle

Макросы Nemerle аналогичны макросам в Boo. На самом деле, макросы являются ключом к метапрограммированию в Nemerle. Вы увидите в следующем разделе, как можно использовать макросы для изменения типов, но давайте начнем с обманчиво простого макроса Nemerle:

using System;
macro @^^(x, y)
	syntax(x, "^^", y) {
		<[ Math.Pow($x, $y) ]>
}

Операторы <| и |> похожи на ` (обратную галочку) в Boo. Таким образом, можно сделать вывод, что этот макрос будет выполнять работу по возведению х в степень у. Кроме того, как и в Boo, макросы должны быть скомпилированы в свои сборки, прежде чем они могут быть использованы в коде Nemerle. Интересная особенность Nemerle заключается в том, что вы можете предоставить синтаксисное ключевое слово, которые Nemerle понимает, когда оно используется в коде. В этом макросе синтаксисом является "^^" с двумя аргументами по обе стороны синтаксисного узла. Вот как это выглядит, когда вы используете макрос:

System.Console.WriteLine (3.0 ^^ 5.0);

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

Не каждое преобразование кода работает на уровне оператора. Добавление трассировки метода (схожего функционала, как атрибут Boo Trace AST, который вы видели в разделе «Внедрение Boo кода при компиляции с помощью AST") требует доступ к самому типу. К счастью, Nemerle предоставляет эту поддержку при помощи пользовательских атрибутов. Давайте посмотрим, как можно создать атрибут Nemerle, чтобы добавить трассировку для всех методов в классе.

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

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

Листинг 9-3: Макрос трассировки в Nemerle
using Nemerle;
using Nemerle.Compiler;
using Nemerle.Compiler.Parsetree.ClassMember;
using System;
[MacroUsage(MacroPhase.WithTypedMembers, MacroTargets.Class,
	Inherited = true, AllowMultiple = false)]
macro Trace (type : TypeBuilder)
{
	foreach (method : IMethod in type.GetMethods(
		BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static))
	{
		match(method)
		{
			| builtMethod is MethodBuilder =>
				builtMethod.Body =
				<[
					Console.Out.WriteLine(string.Format(
						"Method {0} entered.", $(method.Name : string)));
					$(builtMethod.Body);
					Console.Out.WriteLine(string.Format(
						"Method {0} finished.", $(method.Name : string)));
				]>;
			| _ => { }
		}
	}
}

Хотя Nemerle выглядит как C#, он имеет некоторые существенные отличия, поэтому давайте рассмотрим этот код более подробно. Первое, что вам нужно сделать, это отметить этот макрос MacroUsageAttribute. Вы должны указать, на какой фазе процесса компиляции выполняется макрос, при помощи перечисления MacroPhase. Вы можете указать значение, например, BeforeInheritance, но в данном случае вы хотите подождать, пока будут объявлены все методы, поэтому используется WithTypedMembers. Кроме того, необходимо указать целевой элемент; и в этом макросе целью является класс. Далее, необходимо указать некоторые аргументы, чтобы вы могли получить доступ к нужным членам в макросе, и для этого предназначен типовой аргумент на основе TypeBuilder.

Как только ваш макрос вызван, вам необходимо "посетить" все методы типа. GetMethods() возвращает список объектов на основе IMethod, но вам нужно работать с методами типа MethodBuilder. Для этого и нужно выражение match(). Вызвать код, основываясь на определенных условиях, – это подход на основе паттернов. В этом случае модификация кода произойдет только тогда, когда текущий метод имеет тип MethodBuilder. В противном случае макрос ничего не делает с этим методом.

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

Использование этого макроса не требует объявления атрибута:

using System;
module Program
{
	[Trace]
	public class TracedTest
	{
		public TraceMe() : void
		{
			Console.Out.WriteLine("I should have been traced.");
		}
	}
	Main() : void
	{
		TracedTest().TraceMe();
	}
}

Совет

Оба Boo и Nemerle создают .NET сборки, содержащие систему компилятора, определения AST и так далее. Естественно, можно определить макросы для обоих языков в C# или VB, ссылаясь на соответствующие Boo или Nemerle сборки, хотя вам все еще нужно понять, как работают эти целевые языки, чтобы вы могли создать правильные выражения. Вы можете также использовать их движки компилятора, чтобы обеспечить скриптинг или DSL фреймворк в C # или VB. Чтобы узнать о том, как вы можете использовать Boo для создания DSL, посмотрите DSLs in Boo: Domain-Specific Languages in .NET (Manning, 2010). Книга есть на www.manning.com/rahien/.

Теперь вы знаете, как другие языки напрямую используют метапрограммирование. Но хотя эти языки могут казаться привлекательными и интересными, их использование в ваших текущих проектах может оказаться невозможным. Заказчики и компании по разработке ПО не всегда готовы к тому, чтобы их разработчики переключали языки, когда посчитают нужным, так что вам, возможно, придется найти другие способы расширения C# или VB. К счастью, есть инструменты и фреймворки, которые могут добавить некоторые из возможностей, которые есть в Boo и Nemerle. В следующем разделе вы увидите, что это за инструменты и как они работают в C#.