Главная страница   /   6.2. Создание динамических методов при помощи LINQ выражений (Метапрограммирование в .NET

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

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

Кевин Хазард

6.2. Создание динамических методов при помощи LINQ выражений

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

  • Создание и вызов методов
  • Использование математических операций
  • Добавление обработки исключений
  • Управление потоком кода

Прежде чем мы это сделаем, давайте сначала посмотрим, что связывает LINQ с выражениями.

Понимание LINQ выражений

На первый взгляд, нахождение Expression API в пространстве имен System.Linq.Expressions может показаться странным. Что вообще связывает LINQ с выражениями? Лучший способ увидеть, как выражения используются в .NET, это написать что-то при помощи LINQ, декомпилировать это и посмотреть на результат.

В следующем листинге показан простой LINQ запрос, который находит объекты, значения свойств которых содержат символ a.

Листинг 6-2: Использование LINQ для фильтрации списка объектов
public sealed class Container
{
	public string Value { get; set; }
}
// ...
var containers = new Container[]
{
	new Container { Value = "bag" },
	new Container { Value = "bed" },
	new Container { Value = "car" }
};
var filteredResults =
	from container in containers
	where container.Value.Contains("a")
	select container;

Здесь ничего необычного не происходит: вы используете условие where для фильтрации списка. Допустим, что ваша фильтрация более сложная и динамически основана на выборе пользователя в приложении. Пользователь может захотеть, например, найти объекты Container, где свойство Value содержит a и имеет длину от 3 до 10 символов. Вы могли бы написать этот LINQ запрос без проблем, но он был бы жестко закодирован во время компиляции. И нет способа изменить запрос при помощи известных LINQ техник, с которыми знакомы большинство .NET разработчиков. Но если вы знаете выражения, вы можете заменить фильтр на лету. Давайте изменим фильтр из листинга 6-2, чтобы он использовал выражения, и это показано в следующем листинге.

Листинг 6-3: Использование LINQ выражений для создания фильтра
var argument = Expression.Parameter(typeof(Container));
var valueProperty = Expression.Property(argument, "Value");
var containsCall = Expression.Call(valueProperty,
	typeof(string).GetMethod(
		"Contains", new Type[] { typeof(string) }),
	Expression.Constant("a", typeof(string)));
var wherePredicate = Expression.Lambda<Func<Container, bool>>(
	containsCall, argument);
var whereCall = Expression.Call(typeof(Queryable), "Where",
	new Type[] { typeof(Container) },
	containers.AsQueryable().Expression, wherePredicate);
var expressionResults = containers.AsQueryable()
	.Provider.CreateQuery<Container>(whereCall);

Вам нужна ссылка на объект IQueryable, чтобы начать, и для этого предназначен метод расширения AsQueryable(). Затем вы используете свойство Provider для вызова метода CreateQuery(). Этот метод принимает выражение, которое может сделать почти все, что вы хотите, чтобы оно сделало с запрашиваемым объектом. В следующем разделе подробно рассказывается о создании этого выражения, но на данный момент достаточно знать, что вы можете создавать динамические LINQ запросы во время выполнения с помощью Expression API.

На данный момент пришло время (наконец-то!) перейти API в System.Linq.Expression. Мы начнем с выражения, созданного в листинге 6-3, по одной части за один раз.

Использование DynamicQueryable

В примере, который поставляется с установкой Visual Studio, лежит скрытая жемчужина, которая называется System.Linq.Dynamic; это пространство имен содержит класс DynamicQueryable (среди многих других интересных классов). Этот класс позволяет написать динамический запрос, используя небольшой кусок кода, а не явно, используя Expression API. Код из листинга 6-3 сводится к одной строке кода, если использовать DynamicQueryable:

var dynamicResults = containers.AsQueryable()
	.Where("Value.Contains(\"a\")");

Более подробную информацию о том, как использовать этот классный API, ищите на http://mng.bz/KN7v.

Создание выражений во время выполнения

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

Создание простого лямбда выражения

В листинге 6-3 было создано выражение, чтобы выполнить динамический запрос к простому набору данных. Запрос был таким же, как написание этой строки кода:

where container.Value.Contains("a")

Это то же самое, как написание следующей строки кода (то, что C# компилятор будет делать с предыдущим LINQ запросом):

containers.Where(value => value.Value.Contains("a"))

Давайте пройдемся по каждой строке в листинге 6-3 и посмотрим, как выражение переводится в точно такую же строку кода, вызывающую метод Where() для списка.

Первое, что вам нужно, это параметр к лямбда-выражению – для этого предназначен параметр value. Это можно сделать, создав ParameterExpression:

var argument = Expression.Parameter(typeof(Container));

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

Теперь, когда у вас есть параметр типа Container, вам нужно использовать свойство Value для этого параметра. Чтобы получить его, используйте метод Property(), который возвращает MemberExpression:

var valueProperty = Expression.Property(argument, "Value");

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

Следующим шагом является вызов Contains() для свойства. Это то, что делает следующая строка кода:

var containsCall = Expression.Call(valueProperty,
		typeof(string).GetMethod(
		"Contains", new Type[] { typeof(string) }),
	Expression.Constant("a", typeof(string)));

Метод Call() возвращает MethodCallExpression. Вы можете вызвать метод различными способами, так что вы можете себе представить, что есть много перегруженных вариантов для Call(). В вашем случае необходимо вызвать метод для свойства Value, поэтому MemberExpression передается в первую очередь. Далее вы указываете метод, который вы хотите вызвать для свойства. Здесь в коде используется немного рефлексии через GetMethod() для поиска методаContains() для string с правильной сигнатурой. Далее Call() нужны любые значения аргумента. Вам нужно всего лишь передать литеральное строковое значение "a", что и обеспечивает ConstantExpression.

Вы близки к тому, чтобы закончить этот этап. Теперь нужно лямбда-выражение, которое вы передадите вызову Where():

var wherePredicate = Expression.Lambda<Func<Container, bool>>(
	containsCall, argument);

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

Наконец, необходимо вызвать метод Where() для самого запрашиваемого объекта:

var whereCall = Expression.Call(typeof(Queryable), "Where",
	new Type[] { typeof(Container) },
	containers.AsQueryable().Expression, wherePredicate);

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

Давайте двигаться дальше. Теперь мы посмотрим на API, которые могут помочь вам с добавлением математических операций.

Включение математических операций

Давайте вернемся к фрагменту кода из раздела 6.1.1. В этом выражении вы видели, как вы могли создать метод, который сложил бы два числа. Вы делали это с помощью метода Add(), который возвращает BinaryExpression. Многочисленные Expression API возвращают BinaryExpression. Например, при помощи одного изменения вы можете сделать так, чтобы код из раздела 6.1.1 выполнял вычитание:

Expression.Subtract(x, y)

Если вам нужен остаток от х, деленное на у:

Expression.Modulo(x, y)

Вы также можете возвести х в степень y с помощью вызова Power():

Expression.Power(x, y)

В этой функции приятно то, что вам не нужно делать MethodCallExpression для Math.pow(). Используйте этот статический метод. Но вы должны убедиться, что типы х и у определены как тип double. Expression.Power() вызывает Math.pow() для вас, поэтому вы не можете использовать тип int.

У вас также есть возможность использовать проверенные математические операторы, которые вызовут OverflowException. Например, AddChecked() знает этот сценарий. Говоря об исключениях, вы можете задаться вопросом, можно ли добавить обработчики исключений в выражения. Ответ: да, можно, и это то, о чем говорится в следующем разделе.

Обработка исключений

Допустим, вы создали динамический метод, который использует сложение с помощью AddChecked(). Если кто-то передаст два значения, которые могут вызвать переполнение, вы получите исключение. Хотя это может показаться странным, ловить OverflowException, которое вы хотели получить, используя AddChecked(), давайте посмотрим, как вы можете сделать это при помощи выражения, в следующем листинге.

Листинг 6-4: Добавление блока try-catch к выражению
var x = Expression.Parameter(typeof(int));
var y = Expression.Parameter(typeof(int));
var lambda = Expression.Lambda(
	Expression.TryCatch(
		Expression.Block(
			Expression.AddChecked(x, y)),
		Expression.Catch(
			typeof(OverflowException),
			Expression.Constant(0))), x, y);

До 4.0 Expressions API был ограничен в том, что он мог делать. Одно из его ограничений состояло в обработке исключений. Не было никакой возможности добавить блок try...catch к телу выражения. Но с версией 4.0 теперь у вас есть такая поддержка.

Первое, что вам нужно сделать, это добавить обработчик исключений. В листинге 6-4, вызывается TryCatch(), который возвращает TryExpression. Далее, возьмем код, который мы хотим иметь в блоке try и обернем его в BlockExpression. Это то, что делает вызов Block(). Наконец, когда есть блок try...catch, необходимо определить блок кода, который будет запущен, если будет выброшено исключение, что и выполняет вызов Catch(). Обратите внимание, что в листинге 6-4 блок catch определен для перехвата исключений типа OverflowException.

Когда блок try...catch на месте, следующий код вернет 5:

return (lambda.Compile() as Func<int, int, int>)
	(2, 3);

Но этот код вернет 0:

return (lambda.Compile() as Func<int, int, int>)
	(int.MaxValue, int.MaxValue);

Поддержка обработки исключений в Expression API не ограничивается тем, что вы видели здесь. Вы можете создать обработчики try-finally, несколько обработчиков catch; на самом деле, есть вызов TryFault(), который создаст обработчик fault. Вспомните главу 5 раздел 5.1.3, что есть обработчик fault, который не поддерживается ни в C#, ни в VB. С Expression API можно с относительной легкостью добавить эту поддержку, если вы хотите.

Управление потоком кода

Expression API также имеет множество способов поддержки ветвления и управления потоком кода. Давайте рассмотрим простой пример функции, которая принимает bool и возвращает 1, если аргумент является true, и 0, если аргумент является false.

Как и раньше, первое, что вам нужно, это аргумент:

var @switch = Expression.Parameter(typeof(bool));

Далее вызовите метод Condition():

var conditional = Expression.Condition(@switch,
	Expression.Constant(1),
	Expression.Constant(0));

Это довольно просто. Первое выражение должно возвращать значение bool, которое является вашим аргументом. Если оно true, выполняется выражение, указанное вторым аргументом. В противном случае выполняется последнее выражение. Если бы вы видели этот код в C#, он выглядел бы примерно так:

public void AFunction(bool @switch)
{
	if(@switch)
	{
		return 1;
	}
	else
	{
		return 0;
	}
}

Наконец, вы компилируете выражение, находящееся в лямбда-выражении:

var function = (Expression.Lambda(conditional, @switch)
	.Compile() as Func<bool, int>);
var result = function(true);

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

Это охватывает основы выражений в .NET. В Expression API есть так много всего, что мы не охватили – мы только коснулись поверхности того, что вы можете сделать с выражениями. На самом деле, вы не ограничены Expression API по сравнению с тем, что вы можете сделать в IL при помощи DynamicMethod. Есть ли недостатки использования выражений по сравнению с использованием DynamicMethod? Почему вы будете предпочитать выражения использованию DynamicMethod? В следующем разделе вы увидите сравнение этих двух подходов.

Сравнение с динамическими методами

В главе 5 вы познакомились с классом DynamicMethod, который является способом на основе IL для создания метода во время выполнения. Как вы видели в этой главе, Expression API обеспечивает ту же функциональность, поэтому неизбежно возникает вопрос: что из них следует выбрать? Мы рассмотрим это вопрос с двух позиций: абстракции и производительности.

Абстракция – это все об использовании API, который легко понять, например, таком, который разработчик может начать использовать при минимальном обучении. Оба подхода имеют API, которые являются последовательными, но на наш взгляд, избежание IL является предпочтительным подходом. Возможность использовать Expression.Call(), чтобы вызвать метод, проще, чем попытка выяснить правильные коды операций, чтобы переместить локальные переменные или аргументы в стек (наряду с объектами для методов экземпляров). Опять же, в конце концов, это субъективное мнение, но мы использовали оба подхода, и нашим предпочтением является Expression API.

Другая вещь, которую стоит принять во внимание, заключается в том, какова производительность одного подхода и другого. Примеры кода для этой книги содержат код, который сравнивает производительность DynamicMethod в создании дженерик реализации ToString() с использованием Expression API. Мы не будем показывать этот код в книге, поскольку вы уже видели, как работает версия с DynamicMethod, а версия с Expression API длинная. Гораздо более интересно посмотреть на сравнение времени выполнения, что показано на рисунке 6-2.

Рисунок 6-2: Сравнение времени выполнения между выражениями и DynamicMethod. Разницы между ними практически нет.

Как вы видите, большой разницы между ними нет. Есть небольшая выгода во времени выполнения с DynamicMethod, но разница тривиальна. В среднем, исполнение, основанное на выражениях, заняло около 1,48623 микросекунд. Подход с DynamicMethod занял 1,48601 микросекунд. Обе реализации эффективно выполняются с той же скоростью. Ключевым отличием является то, что с Expression API вам не придется изучать IL, чтобы его использовать.

Однако график на рисунке 6-2 немного вводит в заблуждение. Данные были собраны с использованием кешированных версий обоих подходов, тут не принимает во внимание время, необходимое для создания метода.

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

f(x) = ((3 * x) / 2) + 4

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

var parameter = Expression.Parameter(typeof(double));
var method = Expression.Lambda(
	Expression.Add(
		Expression.Divide(
			Expression.Multiply(
				Expression.Constant(3d), parameter),
			Expression.Constant(2d)),
		Expression.Constant(4d)),
	parameter).Compile() as Func<double, double>;

Вот как это делается при помощи DynamicMethod:

var method = new DynamicMethod("m",
	typeof(double), new Type[] { typeof(double) });
var parameter = method.DefineParameter(
	1, ParameterAttributes.In, "x");
var generator = method.GetILGenerator();
generator.Emit(OpCodes.Ldc_R8, 3d);
generator.Emit(OpCodes.Ldarg_0);
generator.Emit(OpCodes.Mul);
generator.Emit(OpCodes.Ldc_R8, 2d);
generator.Emit(OpCodes.Div);
generator.Emit(OpCodes.Ldc_R8, 4d);
generator.Emit(OpCodes.Add);
generator.Emit(OpCodes.Ret);
var compiledMethod = method.CreateDelegate(
	typeof(Func<double, double>)) as Func<double, double>;

Если вы создадите 10 000 таких и вычислите среднюю величину, вы получите график, как на рисунке 6-3.

Рисунок 6-3: Сравнение времени на создание метода при помощи выражения и DynamicMethod. Подход с DynamicMethod, очевидно, быстрее, но требует глубокого знания IL.

Потребовалось в 6,8 раза больше времени при использовании выражений, чем при использовании DynamicMethod. Имейте в виду, однако, что создание метода с помощью выражения потребовало, в среднем, менее 1 мс. Это может быть узким местом производительности для вашего приложения, в зависимости от того, как быстро вам нужно его выполнять, но как только вы создаете метод, вы, вероятно, кэшируете его, так что вы не будете все время его воссоздавать. Кроме того, как только создается метод, нет никакой разницы во времени выполнения между выражением и DynamicMethod. Хотя выражения медленнее, это время создания может быть приемлемо для вашего приложения. Как и с любыми результатами производительности, убедитесь, что вы сделали свою собственную оценку и использовали свои данные, чтобы понять, что подходит для вашего кода.

Теперь у вас есть твердое, базовое понимание выражения в .NET. Но выражение могут больше, чем создание методов. Вы можете их отлаживать, использовать их в Reflection.Emit и менять их. В следующем разделе вы увидите, как вы можете выполнить все три задачи.