Главная страница   /   5.1. Почему классы Reflection.Emit? (Метапрограммирование в .NET

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

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

Кевин Хазард

5.1. Почему классы Reflection.Emit?

Можно, фактически, с уверенностью предположить, что при написании большей части кода, который вы создавали в .NET, вы следовали вот такому общему рабочему процессу:

  • Написать код на любимом языке
  • Скомпилировать его
  • Запустить результат

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

Поддержка DSL

Если вам когда-либо приходилось делать много работы по разбору (парсингу) и обработке текста (на .NET языках или нет), вы, вероятно, использовали нечто, что называется регулярным выражением. Регулярные выражения – это несколько загадочные строки, которые содержат большую мощь, чтобы получить конкретные структурные паттерны из моря символов. Например, на рисунке 5-1 показано регулярное выражение для поиска телефонных номеров.

Рисунок 5-1: Простое регулярное выражение для поиска телефонных номеров

Это может выглядеть не совсем презентабельно, но это выражение может найти в тексте телефонный номер из США, если в нем используется дефис для разделения цифр, например: 123-555-1212. Если вы найдете время, чтобы покопаться в регулярных выражениях, вы сможете делать удивительные вещи по извлечению информации из текстового файла.

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

var phone =
	"Find this: 123-555-1212. Or this: 123-555-9999.";
var matches = Regex.Match(phone,
	@"((\(\d{3}\) ?)|(\d{3}-))?\d{3}-\d{4}");
while(matches.Success)
{
	Console.Out.WriteLine(matches.Value);
	matches = matches.NextMatch();
}

Регулярное выражение является примером DSL. DSL, как вы узнали в главе 1, являются, как правило, более мелкими, легкими языками, приспособленными для решения конкретной проблемы. Они обычно встроены в другие языки и фреймворки. Регулярные выражения сами по себе не подходят для создания сложных приложений, но они могут легко найти ссылки в тексте из файла, которое приложение загрузило в память.

Вопрос по использованию регулярных выражений в .NET заключается в том, что Regex должен разобрать выражение, чтобы перевести его в исполняемый код. Вот где в игру вступают классы Reflection.Emit. Regex использует Reflection.Emit для создания быстрой реализации заданного регулярного выражения, чтобы выполнить операции, определенные в выражении. Если вы создаете собственный DSL и хотите использовать его в коде, вы можете использовать Reflection.Emit для компиляции кода в IL, который может работать так же быстро, как и любой код, написанный на C# или VB.

Примечание

Регулярные выражения не являются специфическими для .NET. Они существовали задолго до того, как коды операций увидели свет, поэтому есть много информации по этим удивительным выражениям. Начинать хорошо с сайта www.regular-expressions.info. Вы также можете посмотреть DSL in Action, написанную Debasish Ghosh (Manning, 2010) http://manning.com/ghosh/.

Превращение кода рефлексии в IL

Есть моменты, когда вам нужно выполнить некоторую обработку, которая не может произойти во время выполнения. Пример ToString() в главе 2 показывает, почему вы, возможно, могли бы захотеть использовать метапрограммирование, чтобы уменьшить объем кода, который вы пишете, и отложить эту обработку, пока она не будет необходима. Вы также видели это с классом Lazy<T> в главе 2. Lazy<T> предоставляет вам возможность отложить загрузку значения, пока пользователь не вызовет свойство Value: в это время Lazy<T> создаст свойство. Если вы никогда вызовите Value, Lazy<T> не будет ничего делать. Аналогично происходит и с сериализацией. Сериализация представляет собой процесс, при котором содержимое объекта сохраняется в каком-то постоянном хранилище, например, памяти или файле. Вы можете десериализовать объект позже, если это необходимо. Некоторые стратегии сериализации могут быть достаточно сложными, и лучше отложить выполнение, пока вы не узнаете, что вам это нужно.

Так обстоит дело с XML сериализацией через класс XmlSerializer. Если вы никогда не видели, как работает XmlSerializer, то это довольно просто. Скажем, у вас был простой объект с несколькими свойствами:

public sealed class DataBucket
{
	public Guid Id { get; set; }
	public string Value { get; set; }
}

Сериализация экземпляра DataBucket занимает всего несколько строк кода:

var target = new DataBucket
{
	Id = Guid.NewGuid(),
	Value = Guid.NewGuid().ToString("N")
};
using(var stream = new StringWriter())
{
	var serializer = new XmlSerializer(typeof(DataBucket));
	serializer.Serialize(stream, target);
	Console.Out.WriteLine(
		stream.GetStringBuilder().ToString());
}

Это выведет следующую XML информацию на окно консоли:

<?xml version="1.0" encoding="utf-16"?>
<DataBucket xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xmlns:xsd="http://www.w3.org/2001/XMLSchema">
	<Id>b3a14833-7fbc-4e09-86b0-0d878055c1e9</Id>
	<Value>ee77b2ea4c664ec798e67ce74fa9eb9f</Value>
</DataBucket>

XmlSerializer использует изрядное количество рефлексии, чтобы выяснить, какие данные находятся в заданном объекте, чтобы выполнить над ним необходимые операции по сериализации. Поскольку возможно написать код, который использует Serialize() и Deserialize(), но не выполняется при запуске приложения, XmlSerializer откладывает создание логики сериализации, пока она не будет нужна.

Этот подход имеет два преимущества. Первое легко заметить: вы выполняете «ленивые» (lazy) вычисления для сериализации, что хорошо, если сериализация никогда не произойдет. Второе, возможно, не так легко увидеть (хотя поскольку вы читали главу 2, вы, наверное, знаете, в чем оно заключается!). Это возможность сохранения и повторного использования динамической логики для будущего использования. Нахождение всей информации об объекте через его метаданные занимает время: помните, рефлексия происходит медленнее, чем сопоставимый скомпилированный код. Кроме того, как только вы выясняете, что должно быть сериализовано в объекте, эта логика не изменится в течение жизненного цикла приложения, так как не изменится определение типа. XmlSerializer использует Reflection.Emit, чтобы сгенерировать сборку во время выполнения, которая содержит все логику сериализации, необходимую для данного объекта.

Это еще одно использование Reflection.Emit. Если вы пишете код, который использует рефлексию, довольно часто вы будете сталкиваться с тем, что вы выполняете логику, основываясь на данном типе или сборке. При использовании некоторой магии Emitter API (как вы увидите в разделе 5.5), вы сможете компилировать вашу логику в исполняемом коде, который можно кэшировать. Как вы увидите в разделе 5.5.3, этот технический прием может дать существенный выигрыш в производительности.

Использование функционала .NET, не поддерживаемого в вашем языке

Большинство .NET разработчиков знакомо с обработчиками исключений. В следующем фрагменте код в блоке catch сработает, если код в блоке try выбросит NotImplementedException:

try
{
	// logic goes here...
}
catch(NotImplementedException)
{
	// Exception handling logic goes here...
}

Вы также можете использовать блок finally, который всегда будет работать независимо от того, что происходит в блоке try:

try
{
	// logic goes here...
}
finally
{
	// Clean-up logic usually goes here...
}

Примечание

Это то, во что оператор using превращает ваш код. Dispose() вызывается для объекта в операторе using внутри блока finally, который гарантирует, что Dispose() будет вызван.

Однако знаете ли вы, есть еще один вид блока обработчика в .NET, который C# и VB не используют? Он называется блок fault, и если бы он был в C#, он мог бы выглядеть примерно так:

try
{
	// logic goes here...
}
fault
{
	// Exception handling logic goes here before it's rethrown...
}

Код в блоке fault будет выполнять только тогда, когда в блоке try будет выброшено исключение. Затем исключение выбрасывается повторно. Это было бы удобно с транзакциями, когда вы вызываете метод Rollback() внутри блока fault: но, увы, ключевое слово fault не реализовано в C# или VB.

При помощи классов Reflection.Emit вы могли бы легко написать код, который обернет код в блоке try…fault, потому что Reflection.Emit поддерживает все функциональные возможности, которые позволяет .NET, а не только то, что вы видите в вашем любимом .NET языке. Другой скрытый функционал возможен на уровне IL:

  • Вызов перегруженных методов, которые отличаются только возвращаемыми типами
  • Выброс исключений, которые не наследуются от класса Exception
  • Создание вызовов метода, известных как tail calls, которые очищают стек перед вызовом метода (очень удобно для предотвращения переполнения стека в сценариях рекурсивного вызова)

Конечно, необходимость использования некоторых из этих дополнительных функций редка (или ее вовсе нет), но приятно знать, что у вас есть все возможности .NET, доступные вам благодаря классам Reflection.Emit. Если вы хотите знать обо всем, что доступно в .NET, вы можете найти множество информации.

Примечание

Существует еще одна возможность CLR для исключений, которую поддерживает VB, а C# нет. Она называется блоком filter, который похож на блок catch с дополнительным булевым выражением. Если это выражение равно true, код в блоке выполняется, в противном случае исключение продолжает раскручивание стека. Эта простая возможность делает VB немного более выразительным в отношении обработки исключений, чем C#.

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