Главная страница   /   6.1. Программирование на выражениях (Метапрограммирование в .NET

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

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

Кевин Хазард

6.1. Программирование на выражениях

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

Понимание кода как данных

В главе 1 мы потратили изрядное количество времени, определяя метапрограммирование. Можно вспомнить, что в разделе «Создание IL во время выполнения при помощи деревьев выражений» был дан высокоуровневый обзор выражений. После этого обзора вы видели методики метапрограммирования в действии, которые были на более низком уровне IL. Это дало вам твердое (и мы думаем, что необходимое) понимание внутренней работы .NET, но чаще всего вам не нужно писать код на IL. Вы, наконец, возвращаетесь к выражениям, основной теме в этой главе. Несмотря на то, что тот раздел дал хороший обзор выражений, пришло время сузить дискуссию до дискретного примера, который вы будете использовать в качестве отправной точки для изучения того, как работают в выражения в .NET.

Рассмотрим следующую функцию:

public int Add(int x, int y)
{
	return x + y;
}

Написать это как выражение в .NET не слишком трудно – вы увидите пример этого очень скоро. Но рассмотрим такую запись:

(+ x y)

Если вы когда-либо занимались языком Lisp, вы знаете, откуда идет эта строка кода. Даже если вы никогда прежде не видели Lisp, вы, вероятно, можете догадаться, что этот код принимает две переменные и складывает их вместе. Есть большая причина того, почему вы видите немного Lisp на данный момент, потому что в своей основе Lisp – это все о выражениях. Например:

(1 2 add)

Это список в Lisp, который содержит три части: 1, 2 и add. Обратите внимание, что нет никакой разницы в форматировании между списком и тем, как работает оператор сложения. Они оба являются выражениями. Код и данные оба выражены одним способом. Такое расположение позволяет обрабатывать функцию, как будто это структура данных, что предоставляет большую гибкость для разработчиков менять и переделывать код в довольно естественной манере. Если вы пишете код на IL, нет никакого естественного способа изменить этот код так же, как вы бы поменяли список элементов. Если вы можете обрабатывать код, как данные, становится естественным менять код так, как вы бы изменили данные. Вот почему такое понятие, как выражения, живет в течение столь длительного времени. Такая гибкость может также привести к возникновению некоторых «заумных» реализаций, но на данный момент мы оставим наше погружение в Lisp на это более умеренном уровне.

Примечание

Lisp является одним из старейших языков программирования и считается источником и вдохновением для многих идей, которые вы, вероятно, считаете само собой разумеющимися во многих языках, например, деревьев и компиляторов, способных откомпилировать самих себя (self-hosted compilers), между прочим. Мы не ожидаем от вас, что вы станете специалистами в Lisp, более того, мы сами даже отдаленно не эксперты по Lisp, но потратить некоторое время на Lisp –это очень неплохо. Вы можете узнать больше о Lisp на http://landoflisp.com. Кроме того, The Joy of Clojure (Amit Rathore, Manning, 2011) охватывает сравнительно молодой язык, который называется Clojure, и на этот язык сильное влияние оказал Lisp. Книгу вы можете найти на http://manning.com/rathore/.

Как бы вы взяли C# функцию add, которую вы видели в начале этого раздела, и превратили ее в выражение? Примерно вот так:

Expression<Func<int, int, int>> add = (x, y) => x + y;

Это не настолько кратко, как синтаксис Lisp, но вот как это работает в C#. Переменная add после этого является лямбда-выражением. Это не функция, которую вы можете выполнить. На самом деле, если бы вы попытались написать это:

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

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

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

Метод Compile() принимает дерево выражений и превращает его в нечто, что может быть выполнено в рантайме, а именно, в IL. На рисунке 6-1 показано, на что похоже дерево с логической точки зрения.

Рисунок 6-1: Логическое представление выражения, которое добавляет два параметра. Как вы можете видеть, IL нигде не найти, потому что вы сосредотачиваете внимание на структуре реализации, а не на кодах операций, которые делают это.

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

К сожалению, вы не можете создать выражение во время выполнения с помощью синтаксиса "жирная стрелка": =>. Вы увидите более подробную информацию о Expression API в этой главе, но следующий фрагмент кода делает то же самое, что и лямбда-выражения, с той лишь разницей, что он использует Expression API явно:

var x = Expression.Parameter(typeof(int));
var y = Expression.Parameter(typeof(int));
	return (Expression.Lambda(
		Expression.Add(x, y), x, y)
			.Compile() as Func<int, int, int>)(2, 3);

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

var method = new DynamicMethod("m",
	typeof(int), new Type[] { typeof(int), typeof(int) });
var x = method.DefineParameter(
	1, ParameterAttributes.In, "x");
var y = method.DefineParameter(
	1, ParameterAttributes.In, "y");
var generator = method.GetILGenerator();
generator.Emit(OpCodes.Ldarg_0);
generator.Emit(OpCodes.Ldarg_1);
generator.Emit(OpCodes.Add);
generator.Emit(OpCodes.Ret);
return (method.CreateDelegate(
	typeof(Func<int, int, int>))
	as Func<int, int, int>)(2, 3);

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

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

Почему я не могу использовать var для моих выражений?

Вы могли заметить, что фрагменты кода в этом разделе используют явное объявление типа для лямбда-выражения. И вот почему. Если бы вы ввели это:

var expression = (x, y) => x + y;

вы имели в виду это?

Func<int, int, int> expression = (x, y) => x + y;

или это?

Expression<Func<int, int, int>> expression =
	(x, y) => x + y;

Без явной типизации компилятор не может сказать, что вы имели в виду, а разница есть. Тип Func или Action превращается в коде в анонимный метод, как только компилятор закончит, а выражение разрешается в вызовы API, которые вы увидите в разделе 6.2.

Чтобы узнать больше подробностей о том, когда ключевое слово var не может быть использовано в C#, посмотрите две статьи на http://mng.bz/l0D0 и http://mng.bz/56bw.

Выражения принимают основное направление метапрограммирования

Вы можете думать, "Отлично, выражения потрясающие, и выразительные и замечательные, но где я буду использовать это в моем коде?" На первый взгляд может показаться, что вы никогда не будете использовать выражения в вашем коде (хотя если вы читаете эту книгу, то, скорее всего, будете). Но много фреймворков, основанных на .NET, используют выражения в той или иной степени. В частности, Компоненто-ориентированная масштабируемая логическая архитектура (Component-based Scalable Logical Architecture, CSLA), которая является фреймворком для бизнес объектов. Наша цель в данном разделе не заключается в том, чтобы пройти по каждому аспекту CSLA; скорее вы увидите, как CSLA использует выражения «за кулисами».

Если вы когда-либо использовали CSLA, вы знакомы с соглашением по созданию объектов через класс DataPortal. Вы не создаете экземпляр объекта напрямую – портал создает его для вас. Следующий листинг демонстрирует, как можно получить данные для объекта Person с помощью идентификатора Guid.

Листинг 6-1: Получение данных для объекта в CSLA
[Serializable]
public sealed class Person
	: BusinessBase<Person>
{
	private Person()
		: base() { }
	public static Person Fetch(Guid id)
	{
		return DataPortal.Fetch<Person>(id);
	}
	private void DataPortal_Fetch(Guid id)
	{
		// ...
	}
}

Обычно вы создаете статический метод Fetch(), который принимает все значения, необходимые ему для успешного поиска. Вы передаете эти значения в метод Fetch() из DataPortal. DataPortal создает экземпляр цели, заданный значением дженерик параметра, а затем ищет метод DataPortal_Fetch, который имеет параметр с правильным типом. В данном случае есть метод, который принимает Guid, так что все будет работать.

Примечание

Если ваш статический метод Fetch() принимает несколько параметров, вы должны упаковать их в один criteria object. Использование Tuple делает это относительно безболезненным.

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

var method = typeof(T).GetMethod(
	"DataPortal_Fetch",
	BindingFlags.Public | BindingFlags.NonPublic |
		BindingFlags.DeclaredOnly | BindingFlags.Instance,
	null,
	new Type[] { criteria.GetType() }, null)

Конкретная реализация в CSLA более вовлечена, чем эта, но она сводится к поиску метода с помощью рефлексии. Как только CSLA узнает, что метод существует, то она создает метод динамически во время выполнения, чтобы выяснить поток вызовов, и кэширует это в памяти. В главе 5 (раздел 5.5.3) вы видели пользу сохранения динамических методов, как только они создаются, поэтому CSLA делает это. Но CSLA не использует DynamicMethod, она использует Expression API для создания этого нового метода. Когда CSLA была впервые создана для .NET, она первоначально использовала исключительно рефлексию, и как только был введен DynamicMethod, CSLA переключилась на него из соображений производительности. Но Expression API создает код с теми же характеристиками производительности, не требуя от разработчика понимать IL, поэтому CSLA использует Expression API для сценариев вызовов метода, как DataPortal_Fetch.

Совет

CSLA доступен в Nuget. Скачать исходный код можно на www.lhotka.net/cslanet/Download.aspx. Если вас интересует то, как CSLA использует Expression API, взгляните на класс DynamicMethodHandlerFactory.

Теперь, когда вы на высоком уровне рассмотрели выражения и то, где они существуют в мире .NET, давайте взглянем на механику выражений. Сначала мы рассмотрим, почему выражения существуют в подпространстве имен LINQ.