Я много раз сталкивался с задачей динамической генерации кода (например, при написании эффективного сериализатора или компилятора DSL). Это можно делать разными способами, какой из них лучший – дискуссия для отдельной статьи. По ряду причин я предпочитаю Reflection.Emit и CIL (Common Intermediate Language) и расскажу, с какими проблемами пришлось столкнуться на этом пути, а также об их решении: умной обертке над ILGenerator – GroboIL из библиотеки Graceful Emit.
Хочу отметить при этом, что иногда встречаются ситуации, когда у нас нет большого выбора: например, при написании сериализатора необходимо иметь доступ к приватным полям, и приходится использовать IL. Кстати, известный сериализатор protobuf-net содержит несколько сотен IL-инструкций.
Если вы ни разу не сталкивались с использованием IL-кода, то статья может показаться сложной для понимания, поскольку содержит много примеров кода с использованием IL. Для получения базовых знаний рекомендую прочитать статью Introduction to IL Assembly Language.
Reflection.Emit предоставляет два способа генерации кода – DynamicMethod и TypeBuilder/MethodBuilder.
DynamicMethod – это «легковесный» статический метод, результатом компиляции которого будет делегат. Основное их преимущество в том, что DynamicMethod'ам разрешается игнорировать видимость типов и членов типов. Они собираются сборщиком мусора, когда все ссылки на них будут сброшены, но с .NET Framework 4.0 такая возможность появилась и у DynamicAssembly, так что это уже не является преимуществом.
С помощью DynamicAssembly/ModuleBuilder/TypeBuilder/MethodBuilder можно динамически генерировать все пространство типов .NET: интерфейсы, классы, переопределять виртуальные методы, объявлять поля, свойства, реализовывать конструкторы и т. д. То есть это будет обычная assembly, которую можно даже сохранить на диск.
На практике чаще используются DynamicMethod'ы, поскольку они несколько проще в объявлении и имеют доступ к приватным членам. MethodBuilder'ы обычно используются, если помимо кода есть необходимость сгенерировать какие-то данные: тогда их удобно поместить в TypeBuilder'ы, а код – в их методы.
Задача: напечатать все поля объекта.
Начнем с того, что у ILGenerator'а плохой синтаксис: есть один метод Emit с кучей перегрузок, поэтому легко по ошибке вызвать неправильную перегрузку.
Также неудобно, что у одной логической IL-инструкции может быть несколько вариантов, например, у инструкции ldelem есть 11 вариантов – ldelem.i1 (sbyte), ldelem.i2 (short), ldelem.i4 (int), ldelem.i8 (long), ldelem.u1 (byte), ldelem.u2 (ushort), ldelem.u4 (uint), ldelem.r4 (float), ldelem.r8 (double), ldelem.i (native int), ldelem.ref (reference type).
Но это все семечки по сравнению с тем, насколько плохо выдаются сообщения об ошибках.
Во-первых, исключение вылетает только в самом конце, при попытке компиляции метода JIT-компилятором (то есть даже не на вызове DynamicMethod.CreateDelegate() или TypeBuilder.CreateType(), а при первой попытке реального запуска этого кода), поэтому не понятно, какая именно инструкция вызвала ошибку.
Во-вторых, сами сообщения об ошибках, как правило, ни о чем не говорят, к примеру, самая частая ошибка – «Common language runtime detected an invalid program».
Если текст функции состоит из десятка инструкций, то еще можно как-то, перечитав код несколько раз, понять, в чем же ошибка, но если код состоит из сотен команд, то разработка такого кода становится очень муторным и долгим занятием.
Если же все же удается заставить такой код скомпилироваться, то дебагать его невозможно. Единственное, что можно сделать, это помимо кода сгенерировать еще символьную информацию, но это долго, неудобно и сложно поддерживать в актуальном состоянии.
Поэтому, имея достаточно большой опыт написания IL-кода с помощью ILGenerator и порядком измучившись, я решил написать свой, учтя все проблемы, на которые я наталкивался.
Задача была написать такой IL-генератор, чтобы исключение InvalidProgramException вообще никогда бы не вылетало, а подхватывалось где-то раньше с понятным текстом ошибки.
Результатом стал GroboIL – умная обертка над ILGenerator.
Особенности GroboIL:
Предыдущий пример, переписанный с использованием GroboIL:
Пробежимся по всем предыдущим ошибкам и посмотрим, как это будет выглядеть с GroboIL'ом.
Помимо прочего, GroboIL формирует дебаг-текст генерируемого IL-кода, где справа от каждой инструкции написано содержимое стэка, который можно получить, вызвав GroboIL.GetILCode(), например:
Ну и напоследок, имеется возможность дебагать MethodBuillder'ы. В этом случае GroboIL автоматически строит символьную информацию, где исходным текстом является приведенный выше дебаг-текст.
Пример:
Теперь ставим брэйкпоинт на строку inst.Sum(10, 3.14); и нажимаем F11 (step into), выпадет диалоговое окно:
В открывшемся окне выбираем папку, куда был сложен дебаг-файлик, и увидим примерно следующее:
Этот файл Visual Studio воспринимает как обычный исходник, можно дебагать по F10/F11, ставить брэйкпоинты, в watch можно вводить параметры функции, this, локальные переменные.
К сожалению, так же красиво дебагать DynamicMethod'ы не получится, поскольку у них отсутствует встроенный механизм построения символьной информации (если кто-то из читателей знает такой способ, я был бы рад услышать). Но, так как IL-команды одинаковые как для DynamicMethod'а, так и для MethodBuilder'а, то можно спроектировать код так, что в нем будет легко подменить DynamicMethod на MethodBuilder для дебага, а в релиз-версии отключить.
С высоты своего пятилетнего опыта генерации IL-кода могу сделать следующий вывод: разница в разработке кода на ILGenerator и GroboIL сравнима с разницей в разработке на C# в VisualStudio с решарпером и разработке в блокноте с компилятором, который говорит ответ в виде Accepted/Rejected без номера строки с ошибкой. Разница в скорости разработки – на порядок. На мой взгляд, GroboIL позволяет генерировать IL-код практически с той же скоростью, что и генерировать, например, C#-код, оставляя при этом все преимущества языка низкого уровня.
Хочу отметить при этом, что иногда встречаются ситуации, когда у нас нет большого выбора: например, при написании сериализатора необходимо иметь доступ к приватным полям, и приходится использовать IL. Кстати, известный сериализатор protobuf-net содержит несколько сотен IL-инструкций.
Если вы ни разу не сталкивались с использованием IL-кода, то статья может показаться сложной для понимания, поскольку содержит много примеров кода с использованием IL. Для получения базовых знаний рекомендую прочитать статью Introduction to IL Assembly Language.
Reflection.Emit предоставляет два способа генерации кода – DynamicMethod и TypeBuilder/MethodBuilder.
DynamicMethod – это «легковесный» статический метод, результатом компиляции которого будет делегат. Основное их преимущество в том, что DynamicMethod'ам разрешается игнорировать видимость типов и членов типов. Они собираются сборщиком мусора, когда все ссылки на них будут сброшены, но с .NET Framework 4.0 такая возможность появилась и у DynamicAssembly, так что это уже не является преимуществом.
С помощью DynamicAssembly/ModuleBuilder/TypeBuilder/MethodBuilder можно динамически генерировать все пространство типов .NET: интерфейсы, классы, переопределять виртуальные методы, объявлять поля, свойства, реализовывать конструкторы и т. д. То есть это будет обычная assembly, которую можно даже сохранить на диск.
На практике чаще используются DynamicMethod'ы, поскольку они несколько проще в объявлении и имеют доступ к приватным членам. MethodBuilder'ы обычно используются, если помимо кода есть необходимость сгенерировать какие-то данные: тогда их удобно поместить в TypeBuilder'ы, а код – в их методы.
Пример
Задача: напечатать все поля объекта.
public static Action<T> BuildFieldsPrinter<T>() where T : class
{
var type = typeof(T);
var method = new DynamicMethod(Guid.NewGuid().ToString(), // имя метода
typeof(void), // возвращаемый тип
new[] {type}, // принимаемые параметры
typeof(string), // к какому типу привязать метод, можно указывать, например, string
true); // просим доступ к приватным полям
var il = method.GetILGenerator();
var fieldValue = il.DeclareLocal(typeof(object));
var toStringMethod = typeof(object).GetMethod("ToString");
var fields = type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
foreach(var field in fields)
{
il.Emit(OpCodes.Ldstr, field.Name + ": {0}"); // stack: [format]
il.Emit(OpCodes.Ldarg_0); // stack: [format, obj]
il.Emit(OpCodes.Ldfld, field); // stack: [format, obj.field]
if(field.FieldType.IsValueType)
il.Emit(OpCodes.Box, field.FieldType); // stack: [format, (object)obj.field]
il.Emit(OpCodes.Dup); // stack: [format, obj.field, obj.field]
il.Emit(OpCodes.Stloc, fieldValue); // fieldValue = obj.field; stack: [format, obj.field]
var notNullLabel = il.DefineLabel();
il.Emit(OpCodes.Brtrue, notNullLabel); // if(obj.field != null) goto notNull; stack: [format]
il.Emit(OpCodes.Ldstr, "null"); // stack: [format, "null"]
var printedLabel = il.DefineLabel();
il.Emit(OpCodes.Br, printedLabel); // goto printed
il.MarkLabel(notNullLabel);
il.Emit(OpCodes.Ldloc, fieldValue); // stack: [format, obj.field]
il.EmitCall(OpCodes.Callvirt, toStringMethod, null); // stack: [format, obj.field.ToString()]
il.MarkLabel(printedLabel);
var writeLineMethod = typeof(Console).GetMethod("WriteLine", new[] { typeof(string), typeof(object) });
il.EmitCall(OpCodes.Call, writeLineMethod, null); // Console.WriteLine(format, obj.field.ToString()); stack: []
}
il.Emit(OpCodes.Ret);
return (Action<T>)method.CreateDelegate(typeof(Action<T>));
}
Проблемы ILGenerator
Начнем с того, что у ILGenerator'а плохой синтаксис: есть один метод Emit с кучей перегрузок, поэтому легко по ошибке вызвать неправильную перегрузку.
Также неудобно, что у одной логической IL-инструкции может быть несколько вариантов, например, у инструкции ldelem есть 11 вариантов – ldelem.i1 (sbyte), ldelem.i2 (short), ldelem.i4 (int), ldelem.i8 (long), ldelem.u1 (byte), ldelem.u2 (ushort), ldelem.u4 (uint), ldelem.r4 (float), ldelem.r8 (double), ldelem.i (native int), ldelem.ref (reference type).
Но это все семечки по сравнению с тем, насколько плохо выдаются сообщения об ошибках.
Во-первых, исключение вылетает только в самом конце, при попытке компиляции метода JIT-компилятором (то есть даже не на вызове DynamicMethod.CreateDelegate() или TypeBuilder.CreateType(), а при первой попытке реального запуска этого кода), поэтому не понятно, какая именно инструкция вызвала ошибку.
Во-вторых, сами сообщения об ошибках, как правило, ни о чем не говорят, к примеру, самая частая ошибка – «Common language runtime detected an invalid program».
Примеры ошибок/опечаток
-
var il = dynamicMethod.GetILGenerator(); {..} // Здесь какие-то инструкции il.Emit(OpCodes.Ldfld); // Пытаемся загрузить поле, но забыли передать FieldInfo {..} // Здесь какие-то инструкции var compiledMethod = dynamicMethod.CreateDelegate(..); compiledMethod(..); // ← Здесь вылетит исключение
InvalidProgramException: «Common language runtime detected an invalid program».
-
var il = dynamicMethod.GetILGenerator(); {..} // Здесь какие-то инструкции il.Emit(OpCodes.Box); // Хотели скастовать value type к object, но забыли передать тип {..} // Здесь какие-то инструкции var compiledMethod = dynamicMethod.CreateDelegate(..); compiledMethod(..); // ← Здесь вылетит исключение
InvalidProgramException: «Common language runtime detected an invalid program».
-
var il = dynamicMethod.GetILGenerator(); {..} // Здесь какие-то инструкции var code = GetCode(..); // Функция возвращает byte il.Emit(OpCodes.Ldc_I4, code); // Хотели загрузить константу типа int, но передали byte {..} // Здесь какие-то инструкции var compiledMethod = dynamicMethod.CreateDelegate(..); compiledMethod(..); // ← Здесь вылетит исключение
InvalidProgramException: «Common language runtime detected an invalid program».
-
var il = dynamicMethod.GetILGenerator(); {..} // Здесь какие-то инструкции il.Emit(OpCodes.Call, abstractMethod); // Хотели вызвать абстрактный метод, но случайно вместо Callvirt написали Call {..} // Здесь какие-то инструкции var compiledMethod = dynamicMethod.CreateDelegate(..); compiledMethod(..); // ← Здесь вылетит исключение
BadImageFormatException: «Invalid il format».
-
var il = dynamicMethod.GetILGenerator(); {..} // Здесь какие-то инструкции var keyGetter = typeof(KeyValuePair<int, int>).GetProperty("Key").GetGetMethod(); il.Emit(OpCodes.Ldarg_1); // Аргумент 1 – KeyValuePair<int, int> il.Emit(OpCodes.Call, keyGetter); // Хотели взять свойство Key у KeyValuePair<int, int>, но это value type, // поэтому его нужно загружать по адресу, чтобы вызвать метод {..} // Здесь какие-то инструкции var compiledMethod = dynamicMethod.CreateDelegate(..); compiledMethod(..); // ← Здесь вылетит исключение
InvalidProgramException: «Common language runtime detected an invalid program».
-
var il = dynamicMethod.GetILGenerator(); {..} // Здесь какие-то инструкции var toStringMethod = typeof(object).GetMethod("ToString"); il.Emit(OpCodes.Ldarga, 1); // Аргумент 1 – int, загрузили по адресу il.Emit(OpCodes.Callvirt, toStringMethod); // Хотели вызвать int.ToString(), но для вызова виртуального метода // на value type по адресу нужен префикс constrained {..} // Здесь какие-то инструкции var compiledMethod = dynamicMethod.CreateDelegate(..); compiledMethod(..); // ← Здесь вылетит исключение
NullReferenceException: «Object reference not set to instance of an object».
Или
AccessViolationException: «Attempted to read or write protected memory. This is often an indication that other memory is corrupt».
-
var il = dynamicMethod.GetILGenerator(); {..} // Здесь какие-то инструкции var bindingFlags = BindingFlags.Instance | BindingFlags.NonPublic; // Хотим достать приватное поле value var valueField = typeof(KeyValuePair<int, string>).GetField("value", bindingFlags); il.Emit(OpCodes.Ldarga, 1); // Аргумент 1 – KeyValuePair<string, int> il.Emit(OpCodes.Ldfld, valueField); // Хотели взять поле value у KeyValuePair<string, int>, но случайно вместо // KeyValuePair<string, int> написали KeyValuePair<int, string>, в итоге // возьмем поле key типа int и проинтерпретируем его как string {..} // Здесь какие-то инструкции var compiledMethod = dynamicMethod.CreateDelegate(..); var result = compiledMethod(..); // ← Здесь не будет исключения {..} // Какая-то работа с result ← Будет исключение
Неопределенное поведение, скорее всего, будет AccessViolationException или NullReferenceException.
- Забыли в конце кода вызвать инструкцию OpCodes.Ret – получим неопределенное поведение: может, вылетит исключение при попытке компиляции, может просто все сломаться уже во время работы, а может повезти и все будет работать правильно.
- Реализуем функцию
static int Add(int x, double y) { return x + (int)y; }
var il = dynamicMethod.GetILGenerator(); il.Emit(OpCodes.Ldarg_0); // Аргумент 0 - типа int il.Emit(OpCodes.Ldarg_1); // Аргумент 1 - типа double il.Emit(OpCodes.Add); // Забыли сконвертировать double к int. Непонятно что будет il.Emit(OpCodes.Ret); var compiledMethod = dynamicMethod.CreateDelegate(..); var result = compiledMethod(..); // ← Здесь может не быть исключения
В спецификации CIL сказано, что инструкция OpCodes.Add не может принимать аргументы типов int и double, но исключения может не быть, просто будет неопределенное поведение, зависящее от JIT-компилятора.
Пример запуска:
- x64: compiledMethod(10, 3.14) = 13
ASM-код (x лежит в ecx, y — в xmm1):
cvtsi2sd xmm0, ecx
addsd xmm0, xmm1
cvttsd2si eax, xmm0
- x86: compiledMethod(10, 3.14) = 20
ASM-код (x лежит в ecx, y — на стэке):
mov eax, ecx
fld qword [esp + 4]
add eax, ecx
fstp st(0)
То есть под x64 сгенерировалась наиболее логичная интерпретация (int конвертируется к double, потом два double складываются и результат обрезается до int), а вот под x86 попытка смешения целочисленных и вещественных операндов привела к тому, что вместо x + y возвращается 2 * x (читателям предлагаю посмотреть, что будет, если вместо int + double написать double + int).
- x64: compiledMethod(10, 3.14) = 13
- Реализуем функцию
static string Coalesce(string str) { return str ?? ""; }
var il = dynamicMethod.GetILGenerator(); il.Emit(OpCodes.Ldarg_0); // stack: [str] il.Emit(OpCodes.Dup); // stack: [str, str] var notNullLabel = il.DefineLabel(); il.Emit(OpCodes.Brtrue, notNullLabel); // if(str != null) goto notNull; stack: [str] il.Emit(OpCodes.Ldstr, ""); // Oops, забыли, что на стэке еще осталось значение str il.MarkLabel(notNullLabel); // В этом месте у нас неконсистентный стэк: в нем либо одно значение, либо два il.Emit(OpCodes.Ret); var compiledMethod = dynamicMethod.CreateDelegate(..); compiledMethod(..); // ← Здесь вылетит исключение
InvalidProgramException: «JIT compiler encountered an internal limitation».
Сюда же подпадает большое количество похожих ошибок: забыли положить this для вызова instance-метода, забыли положить аргумент метода, положили не то значение аргумента метода и т. д.
Если текст функции состоит из десятка инструкций, то еще можно как-то, перечитав код несколько раз, понять, в чем же ошибка, но если код состоит из сотен команд, то разработка такого кода становится очень муторным и долгим занятием.
Если же все же удается заставить такой код скомпилироваться, то дебагать его невозможно. Единственное, что можно сделать, это помимо кода сгенерировать еще символьную информацию, но это долго, неудобно и сложно поддерживать в актуальном состоянии.
Поэтому, имея достаточно большой опыт написания IL-кода с помощью ILGenerator и порядком измучившись, я решил написать свой, учтя все проблемы, на которые я наталкивался.
Задача была написать такой IL-генератор, чтобы исключение InvalidProgramException вообще никогда бы не вылетало, а подхватывалось где-то раньше с понятным текстом ошибки.
GroboIL
Результатом стал GroboIL – умная обертка над ILGenerator.
Особенности GroboIL:
- Более удобный синтаксис: на каждую инструкцию по одной функции, все похожие инструкции объединены вместе, например, вместо 11 инструкций OpCodes.Ldelem_* есть один метод GroboIL.Ldelem(Type type).
- Во время генерации кода GroboIL формирует содержимое стэка вычислений и валидирует аргументы инструкций, и если что-то пошло не так, то тут же кидает исключение.
- Есть дебаг-вывод генерируемого кода.
- Есть возможность дебага MethodBuilder'ов.
- Приемлемая производительность. Например, как-то мне пришлось столкнуться с функцией из 500 000 инструкций, и обработка заняла 3 секунды (при этом компиляция метода JIT-компилятором заняла 84 секунды и отъела 4ГБ памяти).
Предыдущий пример, переписанный с использованием GroboIL:
public static Action<T> BuildFieldsPrinter<T>() where T : class
{
var type = typeof(T);
var method = new DynamicMethod(Guid.NewGuid().ToString(), // имя метода
typeof(void), // возвращаемый тип
new[] { type }, // принимаемые параметры
typeof(string), // к какому типу привязать метод, можно указывать, например, string
true); // просим доступ к приватным полям
using(var il = new GroboIL(method))
{
var fieldValue = il.DeclareLocal(typeof(object), "fieldValue");
var toStringMethod = typeof(object).GetMethod("ToString");
var fields = type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
foreach(var field in fields)
{
il.Ldstr(field.Name + ": {0}"); // stack: [format]
il.Ldarg(0); // stack: [format, obj]
il.Ldfld(field); // stack: [format, obj.field]
if(field.FieldType.IsValueType)
il.Box(field.FieldType); // stack: [format, (object)obj.field]
il.Dup(); // stack: [format, obj.field, obj.field]
il.Stloc(fieldValue); // fieldValue = obj.field; stack: [format, obj.field]
var notNullLabel = il.DefineLabel("notNull");
il.Brtrue(notNullLabel); // if(obj.field != null) goto notNull; stack: [format]
il.Ldstr("null"); // stack: [format, "null"]
var printedLabel = il.DefineLabel("printed");
il.Br(printedLabel); // goto printed
il.MarkLabel(notNullLabel);
il.Ldloc(fieldValue); // stack: [format, obj.field]
il.Call(toStringMethod); // stack: [format, obj.field.ToString()]
il.MarkLabel(printedLabel);
var writeLineMethod = typeof(Console).GetMethod("WriteLine", new[] { typeof(string), typeof(object) });
il.Call(writeLineMethod); // Console.WriteLine(format, obj.field.ToString()); stack: []
}
il.Ret();
}
return (Action<T>)method.CreateDelegate(typeof(Action<T>));
}
Пробежимся по всем предыдущим ошибкам и посмотрим, как это будет выглядеть с GroboIL'ом.
-
using(var il = new GroboIL(dynamicMethod)) { {..} // Здесь какие-то инструкции il.Ldfld(); // ← Здесь будет ошибка компиляции {..} // Здесь какие-то инструкции }
Будет ошибка компиляции, так как нет перегрузки метода GroboIL.Ldfld() без параметров.
-
using(var il = new GroboIL(dynamicMethod)) { {..} // Здесь какие-то инструкции il.Box(); // ← Здесь будет ошибка компиляции {..} // Здесь какие-то инструкции }
Будет ошибка компиляции, так как нет перегрузки метода GroboIL.Box() без параметров.
-
using(var il = new GroboIL(dynamicMethod)) { {..} // Здесь какие-то инструкции var code = GetCode(..); // Функция возвращает byte il.Ldc_I4(code); // ← Здесь все ок, будет принят int {..} // Здесь какие-то инструкции }
Метод GroboIL.Ldc_I4() принимает int, поэтому byte скастуется к int и все будет правильно.
-
using(var il = new GroboIL(dynamicMethod)) { {..} // Здесь какие-то инструкции il.Call(abstractMethod); // ← Здесь все ок, будет сгенерирована инструкция Callvirt {..} // Здесь какие-то инструкции }
Функция GroboIL.Call() эмитит OpCodes.Call для невиртуальных методов и OpCodes.Callvirt для виртуальных (если нужно вызвать виртуальный метод невиртуально, например, вызвать базовую реализацию, то нужно использовать метод GroboIL.Callnonvirt())
-
using(var il = new GroboIL(dynamicMethod)) { {..} // Здесь какие-то инструкции var keyGetter = typeof(KeyValuePair<int, int>).GetProperty("Key").GetGetMethod(); il.Ldarg(1); // Аргумент 1 – KeyValuePair<int, int> il.Call(keyGetter); // ← Здесь вылетит исключение {..} // Здесь какие-то инструкции }
Валидатор стэка выдаст ошибку, что нельзя вызвать метод на value type:
InvalidOperationException: «In order to call the method 'String KeyValuePair<Int32, String>.get_Value()' on a value type 'KeyValuePair<Int32, String>' load an instance by ref or box it».
-
using(var il = new GroboIL(dynamicMethod)) { {..} // Здесь какие-то инструкции var toStringMethod = typeof(object).GetMethod("ToString"); il.Ldarga(1); // Аргумент 1 – int, загрузили по адресу il.Call(toStringMethod); // ← Здесь вылетит исключение {..} // Здесь какие-то инструкции }
Валидатор стэка выдаст ошибку, что для вызова виртуального метода на value type нужно передать параметр ‘constrained’ (который подставит префикс OpCodes.Constrained):
InvalidOperationException: «In order to call a virtual method 'String Object.ToString()' on a value type 'KeyValuePair<Int32, String>' specify the 'constrained' parameter».
-
using(var il = new GroboIL(dynamicMethod)) { {..} // Здесь какие-то инструкции var bindingFlags = BindingFlags.Instance | BindingFlags.NonPublic; // Хотим достать приватное поле value var valueField = typeof(KeyValuePair<int, string>).GetField("value", bindingFlags); il.Ldarga(1); // Аргумент 1 – KeyValuePair<string, int> il.Ldfld(valueField); // ← Здесь вылетит исключение {..} // Здесь какие-то инструкции }
Валидатор стэка выдаст ошибку, что не может загрузить поле:
InvalidOperationException: «Cannot load the field 'KeyValuePair<Int32, String>.value' of an instance of type 'KeyValuePair<String, Int32>'».
- Есть проверка, что любая программа заканчивается на одну из нескольких допустимых инструкций, в частности, на OpCodes.Ret.
-
using(var il = new GroboIL(dynamicMethod)) { il.Ldarg(0); // Аргумент 0 - типа int il.Ldarg(1); // Аргумент 1 - типа double il.Add(); // ← Здесь вылетит исключение il.Ret(); }
Валидатор стэка выдаст ошибку, что инструкция OpCodes.Add невалидна в текущем контексте:
InvalidOperationException: «Cannot perform the instruction 'add' on types 'Int32' and 'Double'».
-
using(var il = new GroboIL(dynamicMethod)) { il.Ldarg(0); // stack: [str] il.Dup(); // stack: [str, str] var notNullLabel = il.DefineLabel("notNull"); il.Brtrue(notNullLabel); // if(str != null) goto notNull; stack: [str] il.Ldstr(""); // Oops, забыли, что на стэке еще осталось значение str il.MarkLabel(notNullLabel); // ← Здесь вылетит исключение il.Ret(); }
Валидатор стэка выдаст ошибку, что два пути исполнения кода формируют разный стэк вычислений, и покажет содержимое стэка в обоих случаях:
InvalidOperationException: «Inconsistent stack for the label ‘notNull’
Stack #1: [null, String]
Stack #2: [String]»
Debugging
Помимо прочего, GroboIL формирует дебаг-текст генерируемого IL-кода, где справа от каждой инструкции написано содержимое стэка, который можно получить, вызвав GroboIL.GetILCode(), например:
ldarg.0 // [List<T>]
dup // [List<T>, List<T>]
brtrue notNull_0 // [null]
pop // []
ldc.i4.0 // [Int32]
newarr T // [T[]]
notNull_0: // [{Object: IList, IList<T>, IReadOnlyList<T>}]
ldarg.1 // [{Object: IList, IList<T>, IReadOnlyList<T>}, Func<T, Int32>]
call Int32 Enumerable.Sum<T>(IEnumerable<T>, Func<T, Int32>)
// [Int32]
ret // []
Ну и напоследок, имеется возможность дебагать MethodBuillder'ы. В этом случае GroboIL автоматически строит символьную информацию, где исходным текстом является приведенный выше дебаг-текст.
Пример:
public abstract class Bazzze
{
public abstract int Sum(int x, double y);
}
public void Test()
{
var assembly = AppDomain.CurrentDomain.DefineDynamicAssembly(
new AssemblyName("DynAssembly"),
AssemblyBuilderAccess.RunAndCollect); // Хотим, чтобы сборщик собрал Assembly, когда она станет не нужна
var module = assembly.DefineDynamicModule("zzz", "zzz.dll", true); // true - хотим строить символьную информацию
var symWriter = module.GetSymWriter();
var typeBuilder = module.DefineType("Zzz", TypeAttributes.Public | TypeAttributes.Class, typeof(Bazzze));
var method = typeBuilder.DefineMethod(
"Sum",
MethodAttributes.Public | MethodAttributes.Virtual, // Будем перегружать метод базового класса
typeof(int), // Возвращаемый тип
new[] { typeof(int), typeof(double) }); // Типы аргументов
method.DefineParameter(1, ParameterAttributes.None, "x"); // Нужно только для дебага
method.DefineParameter(2, ParameterAttributes.None, "y"); // Эти имена можно вводить в watch
var documentName = typeBuilder.Name + "." + method.Name + ".cil";
var documentWriter = symWriter.DefineDocument(documentName,
SymDocumentType.Text, SymLanguageType.ILAssembly, Guid.Empty); // Здесь можно любые гуиды ставить
using(var il = new GroboIL(method, documentWriter)) // Передаем в конструктор documentWriter
{
il.Ldarg(1); // stack: [x]
il.Ldarg(2); // stack: [x, y]
il.Conv<int>(); // stack: [x, (int)y]
il.Dup(); // stack: [x, (int)y, (int)y]
var temp = il.DeclareLocal(typeof(int), "temp");
il.Stloc(temp); // temp = (int)y; stack: [x, (int)y]
il.Add(); // stack: [x + (int)y]
il.Ret();
File.WriteAllText(Path.Combine(DebugOutputDirectory, documentName), il.GetILCode());
}
typeBuilder.DefineMethodOverride(method, typeof(Bazzze).GetMethod("Sum")); // Перегружаем метод
var type = typeBuilder.CreateType();
var inst = (Bazzze)Activator.CreateInstance(type, new object[0]);
inst.Sum(10, 3.14);
}
Теперь ставим брэйкпоинт на строку inst.Sum(10, 3.14); и нажимаем F11 (step into), выпадет диалоговое окно:
В открывшемся окне выбираем папку, куда был сложен дебаг-файлик, и увидим примерно следующее:
Этот файл Visual Studio воспринимает как обычный исходник, можно дебагать по F10/F11, ставить брэйкпоинты, в watch можно вводить параметры функции, this, локальные переменные.
К сожалению, так же красиво дебагать DynamicMethod'ы не получится, поскольку у них отсутствует встроенный механизм построения символьной информации (если кто-то из читателей знает такой способ, я был бы рад услышать). Но, так как IL-команды одинаковые как для DynamicMethod'а, так и для MethodBuilder'а, то можно спроектировать код так, что в нем будет легко подменить DynamicMethod на MethodBuilder для дебага, а в релиз-версии отключить.
Вывод
С высоты своего пятилетнего опыта генерации IL-кода могу сделать следующий вывод: разница в разработке кода на ILGenerator и GroboIL сравнима с разницей в разработке на C# в VisualStudio с решарпером и разработке в блокноте с компилятором, который говорит ответ в виде Accepted/Rejected без номера строки с ошибкой. Разница в скорости разработки – на порядок. На мой взгляд, GroboIL позволяет генерировать IL-код практически с той же скоростью, что и генерировать, например, C#-код, оставляя при этом все преимущества языка низкого уровня.