Pull to refresh

Генерируем на .Net

Reading time12 min
Views26K
Генерировать код на .Net можно несколькими способами:
  • Reflection Emit. Доступен с версии .Net 1.0.
  • CodeDom. Позволяет создавать динамический код из представления CodeDom или напрямую из исходников, написанных на одном из высокоуровневых языков, например C#, VB или JScript. Доступен с версии .Net 1.0.
  • Expression trees. Доступен с версии .Net 3.5. Позволяет создавать динамический код из представления Expression.

В этой статье я хочу рассказать про технику кодогенерации с использованием Reflection Emit.

Чуть подробнее о способах генерации


Первый — прямая генерация CIL кода (также известного как MSIL или просто IL) для виртуальной машины .Net. В этом случае генерируемый код описывается на языке CIL, который по внешнему виду напоминает ассемблер на стероидах. На выходе вы получаете динамическую сборку (с возможностью её сохранения на диск) с динамическими классами и методами или «голый» динамический метод. Далее пользуетесь сгенерированным добром по вашему усмотрению.
Второй — генерация исходного кода на языке высокого уровня (например, C# или VB), а затем последующая компиляция исходников в CIL. На выходе получаете сборку, созданную соответствующим компилятором.
Третий — генерация из представления Expression Tree. Вы описываете некоторое АСД (AST) с помощью методов Expression, а потом тем же Expression генерируете описанный метод. Внутри Expression переводит свое представление сразу в CIL код, производя при этом некоторую полезную валидацию описанного АСД (AST). Но Expression Tree ограничен по возможностям — вы не можете генерировать свои типы и сборки и, соответственно, сохранять их на диск.

Будем генерировать CIL


Почему на CIL, а не на языке высокого уровня? Генерация на CIL эффективнее, ведь генерация на высокоуровневом языке — это создание исходников на этом языке, а затем их компиляция в CIL. К тому же генерация на языке верхнего уровня требует вовлечение внешнего процесса — компилятора. И еще — это редкая возможность поковыряться с чем-то похожим на ассемблер для .net программиста. Но и у генерации на высокоуровневом языке есть свои плюсы: не надо разбираться с CIL, вы генерируете код на привычном языке. К тому же, исходники такой кодогенерации всегда можно сохранить или сбросить в лог, а потом провалидировать на глаз или даже вставить их в IDE и поотлаживать.

На что это похоже


Для того чтобы генерировать код с помощью Reflection Emit, нужно иметь минимальное представление о ассемблере. В СIL ассемблере нет регистров, смещений и хитрой адресации. А что есть? Есть стек вычислений, все операции работают только с ним, регистров-то нету. При этом стек вычислений так назван неспроста, он не включает в себя локальные переменные и аргументы методов — для CIL это отдельные понятия. Еще есть операции. Они бывают двух типов: обыкновенные ассемблерные (разного рода переходы, математические операции, вызовы методов и т.д.) или CLRные особенные (Box/Unbox, Newobj, Isinst и т.д.). Впрочем, разделение это чисто формальное.

Хватит слов, начнем генерировать


Лучше один раз увидеть, чем сто раз услышать, а еще лучше — подебажить. Это я к тому, что хватит слов, посмотрим на пример.
Пусть задача будет такой: сгенерировать преобразователь одних сущностей в другие. Т.е. есть классы, по сути одинаковые, но с разными названиями свойств. Например:
public class TestSrc
{
	public int SomeID { get; set; }
}

public class TestTarg
{
	public double SomeOtherID { get; set; }
}

Нам надо преобразовывать TestSrc в TestTarg. Пусть наш преобразователь будет выглядеть так:
class Mapper<TIn, TOut> 
{
	protected delegate TOut MapMethod(TIn src);

	public TOut Map(TIn source)
	{...}
  
	private MapMethod GenerateMapMethod(IDictionary<string, string> mapping)
	{...}
}

Метод Map при первом обращении генерирует преобразовывающий метод, вызывая GenerateMapMethod, а при последующих использует уже сгенерированный метод. mapping, который мы передаем на вход GenerateMapMethod — это соответствие полей в сущностях (Key — имя свойства в типе TIn, Value — имя свойства в типе TOut).

Динамическая сборка

Для начала нам нужно сделать выбор: где будет размещаться наш сгенерированный код? Есть два варианта — в динамической сборке или в динамическом методе. И то, и другое создается “на лету“.
Динамическая сборка — это полновесное решение, оно позволяет генерировать настоящие классы и структуры с любым набором методов. Еще плюс динамической сборки — это возможность ее сохранения для последующего использования или анализа того, что вы там нагенерировали. Это для сложных случаев.
Итак, создаем сборку с классом и статическим методом:
protected MapMethod GenerateMapMethod(IDictionary<string, string> mapping)
{
	var dynGeneratorHostAssembly = AppDomain.CurrentDomain.DefineDynamicAssembly(
		new AssemblyName("Test.Gen, Version=1.0.0.1"),
		AssemblyBuilderAccess.RunAndSave);
	var dynModule = dynGeneratorHostAssembly.DefineDynamicModule(
		"Test.Gen.Mod", 
		"generated.dll");
	var dynType = dynModule.DefineType(
		"Test.MapperOne", 
		TypeAttributes.Abstract | TypeAttributes.Sealed | TypeAttributes.Public);
	var dynMethod = dynType.DefineMethod(
		"callme",
		MethodAttributes.Static,
		typeof(TOut),
		new Type[] { typeof(TIn) });
	var prm = dynMethod.DefineParameter(1, ParameterAttributes.None, "source");

	GenerateMapMethodBody(dynMethod.GetILGenerator(), prm, mapping);

	var finalType = dynType.CreateType();
	dynGeneratorHostAssembly.Save("generatedasm.dll");

	var realMethodInfo = finalType.GetMethod(dynMethod.Name);

	var methodToken = dynMethod.GetToken().Token;
	var methodInfo = dynModule.ResolveMethod(methodToken);

	return (MapMethod)Delegate.CreateDelegate(
		typeof(MapMethod),
		(MethodInfo)methodInfo);
}

Что делает этот код? Да в общем-то, что написано, то и делает — определяет динамическую сборку в текущем домене (можно и отдельный создать) и указывает, как мы будем использовать сборку: только запускать, только сохранять или все вместе (определяется перечислением AssemblyBuilderAccess). Достоверно неизвестно будет ли существенный оверхед, если вы укажите AssemblyBuilderAccess.RunAndSave, а сохранять сборку вам не потребуется. В .Net 4 появилась возможность делать выгружаемые динамические сборки (AssemblyBuilderAccess.RunAndCollect). Для того чтобы сборка выгрузилась, никто не должен ссылаться на экземпляры типов этой сборки и сами типы, подробнее смотри здесь.
Далее определяем модуль в сборке. Мы помним, что сборки состоят из модулей, чаще всего одна сборка — один модуль, но могут быть и многомодульные сборки. Модуль соответствует физическому файлу, поэтому при определении модуля указываем для него имя файла.
В модуле определяем тип — это может быть класс или структура. Простой вызов DefineType(«Test.MapperOne») создаст приватный класс MapperOne в неймспейсе Test. Несмотря на то что вам, может, и не придется обращаться к сгенерированным классам и методам по имени, лучше раздавать им аккуратные имена и неймспейсы, потому что, во-первых, они появляются в стек трейсах, а во-вторых, если вы будете анализировать сгенерированную структуру рефлектором, будет понятнее и приятнее. «Стоп!»,— скажет внимательный читатель. Ведь класс у нас получается приватный, да еще и в другой сборке, сможем ли мы к нему обратиться? Ну, по факту, можем. Но если вам хочется, чтобы все было строго корректно, пишите так:
var dynType = dynModule.DefineType("Test.MapperOne", TypeAttributes.Abstract | TypeAttributes.Sealed | TypeAttributes.Public);

Ну и наконец в типе мы определяем наш метод, указав тип возвращаемого им значения и типы входных аргументов метода.
Далее нам надо наполнить наш сгенерированный метод смыслом. Подробно мы этот процесс рассмотрим позже, поэтому вызов GenerateMapMethodBody(dynMethod.GetILGenerator(), prm, mapping) мы пропускаем и смотрим дальше.
После того как все методы в типе сгенерированны, мы должны создать тип, вызвав метод dynType.CreateType(). После этого никакие динамические манипуляции с типом становятся невозможны. Но зато наш тип теперь готов к использованию. До вызова CreateType CLR ничего не знает о нашем типе и о методах в нем. В отличие от сборки, которая появляется в домене сразу после вызова DefineDynamicAssembly, и модуля, который появляется в сборке сразу после вызова DefineDynamicModule.
Один интересный момент: когда мы определяли динамический тип методом DefineType, он нам вернул TypeBuilder. Если посмотреть на TypeBuilder, то он унаследован от Type, но не ко всем методам Type можно обратиться, если ваш Type — это TypeBuilder. Если задуматься — это логично, ведь типа как такового еще нет. Некоторые свойства Type переопределены так, что всегда возвращают NotSupportedException. Некоторые методы бросают исключение до вызова CreateType, а после начинают перенаправлять вызовы к соответствующему RuntimeType. Похожая ситуация с классом MethodBuilder, который унаследован от MethodInfo. В MethodBuilder также реализованы не все свойства и методы. Еще ситуация усложняется тем, что, например, Delegate.CreateDelegate принимает MethodInfo вторым аргументом, но если вы попробуете передать туда MethodBuilder, то в ответ получите исключение (даже после вызова CreateType). Так что будьте внимательны.

Динамический метод

Но допустим, вам не нужна сборка, не нужны собственные типы, вы хотите просто сгенерировать мааааленький метод. Тогда вам больше подойдет «эконом» предложение — динамический метод. Вместо кучи кода из предыдущего раздела пишем следующее:
protected MapMethod GenerateMapMethod2(IDictionary<string, string> mapping)
{
	var dynMethod = new DynamicMethod("callme", typeof(TOut), new Type[] { typeof(TIn) });
	var prm = dynMethod.DefineParameter(1, ParameterAttributes.None, "source");

	GenerateMapMethodBody(dynMethod.GetILGenerator(), prm, mapping);

	return (MapMethod)dynMethod.CreateDelegate(typeof(MapMethod));
}

Создали метод, наполнили его смыслом и вернули для него делегат. Готово. Хотя в документации сказано, что такому методу не нужна динамическая сборка, модуль и тип, если вы воспользуетесь рефлексией или ProcessExplorer’ом, то увидите что динамическая сборка все-таки создалась (одна для всех динамических методов домена). И даже манифестный модуль в ней есть, но вот найти (рефлексией) в ней наш метод я не смог. Тем не менее он есть и работает. Динамический метод и вся память, выделенная для его генерации, может быть освобождена после того, как на него никто не будет ссылаться. Следовательно, такой способ будет даже чуть быстрее и экономичнее. В данном случае мы пользуемся анонимным хостингом (anonymously hosted) для метода, но есть вариант “прилепить” наш метод к уже существующему модулю или даже классу. Для этого есть специальные конструкторы, которые принимают модуль или тип, к которому мы как бы добавляем динамический метод. В случае с модулем тип становиться глобальным для модуля и имеет доступ ко всем типам модуля, включая внутренние (internal). В случае с классом мы еще будем иметь доступ ко всем внутренним полям класса. Но даже если вы используете “анонимный” хостинг, у вас все равно есть возможность обращаться к внутренним классам и даже внутренним полям этих классов из динамического метода. Для этого надо воспользоваться конструктором с параметром skipVisibility и задать этот параметр равным true (этот параметр указывает на пропуск JIT верификации, не путать с CAS верификацией). Кстати, возможность использовать “анонимный” хостинг появилась только в .Net 3.0.

Тело метода

И вот мы подошли к самому интересному — как же сгенерировать код? В нашем примере код генерирует метод GenerateMapMethodBody(dynMethod.GetILGenerator(), prm, mapping). В этот метод мы передаем ILGenerator, параметр и маппинг — соответствие полей одного класса другому. Класс ILGenerator позволяет вставлять команды CIL в тело генерируемого метода. Делает он это методом Emit. Также ILGenerator позволяет делать метки для переходов методом DefineLabel (для организации условных конструкций), объявлять локальные переменные методом DefineLocal, делать блоки для исключений. Для последнего используется целый набор методов вида BeginCatchBlock, BeginExceptFilterBlock и т.д. Большинство команд в CIL работают со стеком вычисления (evaluation stack, далее для краткости просто стек). CLR следит, чтобы вы не вышли за границы стека, как в одну, так и в другую сторону. Если стек переполнится, вы получите StackOverflowException, если вы попытаетесь взять значение из пустого стека или значение, которое положил туда не ваш метод (т.е. метод видит только “свою” часть стека), то получите InvalidProgramException. Аргументы, переданные в ваш метод, не лежат в стеке; чтобы их использовать нужно воспользоваться командой OpCodes.Ldarg. Таким образом, в начале метода стек как бы пуст. Пустым он должен быть и после выполнения метода, иначе опять будет InvalidProgramException. И это один из минусов кодогенерации на CIL: те ошибки которые вы могли бы отловить на этапе компиляции высокоуровневого языка здесь вы получите только во время выполнения, например, ошибки связанные с типизацией или инициализацией переменных.

Удобная техника для генерации IL кода — это написать на высокоуровневом язык, пример того, что вы хотите сгенерировать, скомпилировать его (не забудьте переключиться в Release перед компиляцией, чтобы брать пример с оптимального кода) и посмотреть, как выглядит шаблон нужного кода на CIL. Смотреть на такой шаблонный код удобно рефлектором. Более того, есть даже специальный плагин ReflectionEmitLanguage. Этот плагин показывает не код просматриваемого в рефлекторе метода или типа, а код, который сгенерирует просматриваемый код. Если под рукой не оказалось рефлектора, можно посмотреть шаблон при помощи IL Disassembler(ildasm.exe) из .Net SDK. Он покажет честный CIL, из которого состоит ваш метод. Далее адаптируем шаблон под свои нужды, и все готово. Этим же методом можно узнать, какие модификаторы надо добавить к методу или его классу, чтобы, например, сделать sealed класс или внутренний (internal) виртуальный метод.

Допустим, мы откуда-то знаем соответствие свойств между классами, тогда шаблон будет выглядеть так:
	public static TestTarg GenerateTemplate(TestSrc src)
	{
		var result = new TestTarg();
		result.SomeOtherID = (double)src.SomeID;
		return result;
	}

Компилируем код, смотрим его в IL Disassembler и видим:
.method private hidebysig static class ConsoleApplication1.TestTarg 
        GenerateTemplate(class ConsoleApplication1.TestSrc src) cil managed
{
  // Code size       21 (0x15) 	   - Это так, для справки
  .maxstack  2  			// - Это CLR посчитает сам
  // Это определение локальной переменной
  .locals init ([0] class ConsoleApplication1.TestTarg result)
  // Это создание нового объекта (выделение памяти + вызов конструктора)
  // После вызова в ссылка на объект будет положена в стек  
  IL_0000:  newobj     instance void ConsoleApplication1.TestTarg::.ctor()
  // Сохраняем ссылку в локальной переменной с индексом 0  
  IL_0005:  stloc.0
  // Кладем в стек локальную переменную с индексом 0		
  IL_0006:  ldloc.0
  // Кладем в стек аргумент метода с индексом 0
  IL_0007:  ldarg.0
  // Вызываем метод(геттер для свойства), для этого в стеке должна лежать ссылка на объект а затем аргументы метода. Результат вызова кладется в стек.
  IL_0008:  callvirt   instance int32 ConsoleApplication1.TestSrc::get_SomeID()
  // Конвертируем int в double
  IL_000d:  conv.r8
  // Вызываем сеттер свойства, на этот момент в стеке лежат ссылка на вызываемый объект и аргумент(сконвертированный int)
  IL_000e:  callvirt   instance void ConsoleApplication1.TestTarg::set_SomeOtherID(float64)
  // Загружаем возвращаемый результат 
  IL_0013:  ldloc.0
  // Выходим из метода
  IL_0014:  ret
} // end of method Program::GenerateTemplate

Посмотрев на этот шаблонный код, мы переносим каждую операцию в виде вызова ILGenerator.Emit(), например:
//IL_0000: newobj     instance void ConsoleApplication1.TestTarg::.ctor()
//Превращаем в 
var tTarg = typeof(TOut);
var targetCtor = tTarg.GetConstructor(new Type[0]);
gen.Emit(OpCodes.Newobj, targetCtor);
...
//IL_0006:  ldloc.0
gen.Emit(OpCodes.Ldloc, locResult);
...
//IL_0008:  callvirt   instance int32 ConsoleApplication1.TestSrc::get_SomeID()
var methodSrc = tSrc.GetProperty(methodMap.Key).GetGetMethod();
gen.Emit(OpCodes.Callvirt, methodSrc);
...
//IL_0014:  ret
gen.Emit(OpCodes.Ret);

Если воспользоваться рефлектором с плагином Reflection.Emit, то все станет еще проще, он покажет, какие именно вызовы ILGenerator.Emit() вам нужны.
Вот что покажет плагин для нашего шаблона
public MethodBuilder BuildMethodGenerateTemplate(TypeBuilder type)
{
    // Declaring method builder
    // Method attributes
    System.Reflection.MethodAttributes methodAttributes = 
          System.Reflection.MethodAttributes.Private
        | System.Reflection.MethodAttributes.HideBySig
        | System.Reflection.MethodAttributes.Static;
    MethodBuilder method = type.DefineMethod("GenerateTemplate", methodAttributes);
    // Preparing Reflection instances
    MethodInfo method1 = typeof(TestSrc).GetMethod(/*убрано для краткости*/);
    MethodInfo method2 = typeof(TestTarg).GetMethod(/*цензура*/);
    // Setting return type
    method.SetReturnType(typeof(TestTarg));
    // Adding parameters
    method.SetParameters(
        typeof(TestSrc)
        );
    // Parameter src
    ParameterBuilder src =  method.DefineParameter(1, ParameterAttributes.None, "src");
    ILGenerator gen =  method.GetILGenerator();
    // Preparing locals
    LocalBuilder result =  gen.DeclareLocal(typeof(TestTarg));
    // Writing body
    gen.Emit(OpCodes.Ldloca_S,0);
    gen.Emit(OpCodes.Initobj,TestTarg);
    gen.Emit(OpCodes.Ldloca_S,0);
    gen.Emit(OpCodes.Ldarg_0);
    gen.Emit(OpCodes.Callvirt,method1);
    gen.Emit(OpCodes.Conv_R8);
    gen.Emit(OpCodes.Call,method2);
    gen.Emit(OpCodes.Ldloc_0);
    gen.Emit(OpCodes.Ret);
    // finished
    return method;
}

Справку по каждой операции можно посмотреть в msdn, класс OpCodes содержит определения всех операций. Некоторые команды лучше переносить не «в лоб”. Например, такие команды как stloc.0, чтобы не запутаться, лучше писать не так:
gen.DeclareLocal(yourType);
gen.Emit(OpCodes.Ldloc_0);

а так:
var locResult = gen.DeclareLocal(yourType);
gen.Emit(OpCodes.Ldloc, localVar);

Аналогично можно поступать с параметрами методов.
Обратите внимание, что некоторые конструкции, которые, скажем, в C# выглядят одинаково, в CIL будут различаться. Например:
var c = new RefType();   //Создание reference типа
var a = new RefType[0]; //Создание массива
var s = new ValType();   //Создание value типа

  //Создание reference типа
var targetCtor = typeof(RefType).GetConstructor(new Type[0]);
gen.Emit(OpCodes.Newobj, targetCtor);
//Создание массива
gen.Emit(OpCodes.Newarr, typeof(RefType));
//Создание value типа
LocalBuilder loc =  gen.DeclareLocal(typeof(ValType));
gen.Emit(OpCodes.Ldloca, loc);
gen.Emit(OpCodes.Initobj, typeof(ValType));

Еще хотел бы обратить внимание на ref параметры. Т.к. в случае с ref параметрами в параметре лежит не значение, а ссылка, то и работать на уровне CIL с ней надо по-другому. Это некое подобие косвенной адресации. Если это ref параметр reference типа, то в параметре будет лежать ссылка на ссылку, и простая команда ldarg положит в стек не ссылку на объект, а ту самую ссылку на ссылку(отличный шанс запутаться в двух соснах). Для получения в стеке ссылки на объект надо дополнительно вызвать ldind.ref.
Если это ref параметр value типа (но не структура), то в параметре будет лежать ссылка на значение. И чтобы положить в стек значение, нужно пользоваться не командами ldarg или starg, а ldind или stind.
Похожая ситуация со структурами(с точностью до наоборот). Если у вас есть параметр или переменная типа структуры, то для обращения к ней вы сначала должны положить в стек адрес на структуру. Для этого есть команда ldarga.

Заключение


Загадка: что общего у regexp и генерации CIL? Ответ: крайне сложный реверс инжиниринг. Поэтому не поленитесь обвесить комментариями генерирующий код, чтобы было понятно, что же вы генерируете. Ну или, скажем, в генерирующих методах комментариев должно быть на 80% больше, чем обычно. Если обычно вы не пишите их совсем, то самое время начать.

Наверно, есть еще много вопросов, о которых можно поговорить на тему генерации CIL, но, мне кажется, статья и так уже затянулась. Так что всем удачи, и до новых встреч.

Полезные ссылки:

UPD:
Умные люди в комментах подсказывают, что так же вам может помочь в нелегком деле генерации:

Я с ними не работал и ничего про них сказать не могу.

P.S.
Каждый программист должен написать компилятор, кодогенератор и магазин на ПэХаПэ.
(Еще вариант, не в тему, но мне понравился: Каждый программист должен построить ядро линукса, вырастить БД до терабайта и посадить плавающий баг).
Tags:
Hubs:
+23
Comments16

Articles