Главная страница   /   5.3. Тур по opcode (Метапрограммирование в .NET

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

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

Кевин Хазард

5.3. Тур по opcode

В листинге 5-2 была показана реализация метода в IL форме. На тот момент обсуждение было сосредоточено на метаданных и директивах, а реализация была отложена, поскольку пояснение таких деталей в то время не имело необходимости. Теперь пришло время покрыть эту тему. Чтобы быть эффективными в использовании метапрограммирования при помощи Reflection.Emit, вы должны сначала понять коды операций, так как они являются частью практически каждого вызова метода, который вы будете делать при помощи этого API. В этом разделе вы сможете узнать, как коды операций именуются, и увидеть различные возможности, который они вам предлагают. В следующем разделе вы будете использовать эти новые знания при работе с Reflection.Emit.

Мнемонические паттерны для кодов операций

Для разработчиков видеть сходство между кодами операций в IL и языковыми инструкциями сборки не является редкостью. В некотором смысле, это сравнение довольно обоснованно. Они оба являются краткими и не столь доброжелательными для использования, как языки более высокого уровня. Тем не менее, написание кода в IL не обозначает, что ваш код будет выполняться быстрее, чем то, что создаст компилятор C#. Именно поэтому вы не видите встроенный IL в качестве опции в C# или VB: потому что действительно нет никакого профицита в том, чтобы позволить разработчикам иметь доступ к кодам операций в методе. Но, как мы уже говорили, создание кода через Reflection.Emit означает, что вы должны понять коды операций. Как оказалось, не так уж и сложно уловить, что делают коды операций. Вот строка кода из листинга 5-2:

IL_0022: stloc.0

Все коды операций имеют мнемонический паттерн. Если имя кода операции начинается со st, это обозначает store. loc означает local. Таким образом, этот код операции хранит локальное значение. .0 определяет, где значение будет сохранено, что мы рассмотрим в следующем разделе.

Таблица 5-1 содержит список общих паттернов, которые вы увидите в кодах операций. Список не является полным, но он поможет вам в расшифровке большинства кодов операций, которые вы встретите.

Таблица 5-1: Общие мнемонические паттерны, используемые с кодами операций
Мнемонический паттерн Значение
ld Загрузить значение
ldc Загрузить константное значение
st Сохранить значение
loc Сделать что-то с локальным значением
br Остановиться на заданной точке в методе
arg Сослаться на аргумент
loc Использовать локальную переменную
ovf Определение переполнения
conv Выполнение конверсии
elem Использовать элемент в массиве
fld Использовать поле
call Вызвать метод

Некоторые коды операций, как castclass, довольно легко читаются и не требуют специального перевода. Вы можете легко догадаться, что он приводит объект к указанному типу. Другие коды операций могут показаться не столь очевидными, как conv.ovf.i4. Но если вы посмотрите на таблицу 5-1, вы поймете, что код операции что-то делает с преобразованием значения при определении переполнения. Мы скоро обсудим, что обозначает часть i4.

Легко или нет понять имя на первый взгляд, мы настоятельно рекомендуем вам иметь третью часть документа Partition под рукой, когда вы погрузитесь в имена кодов операций. Она охватывает все подробности о каждом коде операции, доступном в .NET. Теперь же давайте рассмотрим коды операций, которые вы будете часто использовать в коде, основанном на Reflection.Emit.

Примечание

IL_операторы являются метками, сгенерированными ILDasm. Названия не имеют никаких требований по форматированию, также вы не должны использовать их все время. Они нужны только тем кодам операций, которые перемещают поток управления к новому коду операций. Формат ILDasm, используемый для создания меток, выясняет, как далеко вы находитесь в потоке кодов операций благодаря шестнадцатеричному значению. Поэтому IL_0022 обозначает, что вы находитесь на данный момент на 34-м байте в методе. Это полезно знать, когда вы делаете ветвление, потому что вы можете использовать специальные коды операций, чтобы минимизировать размер метода. Мы покрываем ветвление и то, как используются метки, более подробно в разделе 5.3.6.

Использование локальных переменных

Прежде чем мы продолжим, давайте перепишем код, который вы видели в листинге 5-2 на что-то, что немного проще читать. C# компилятор должен создавать некоторые скверные имена для анонимных членов, так что этот листинг является более чистой версией.

Листинг 5-4: Более чистая версия метода Main() из листинга 5-2.
.method private hidebysig static void Main(string[] args) cil managed
{
	.entrypoint
	.maxstack 3
	.locals init (
		[0] class [mscorlib]System.Lazy`1<int32> lazyInteger)
	ldnull
	ldftn int32
		LazyIntegersInIL.Program::LazyIntegerValueFactory()
	newobj instance void class
		[mscorlib]System.Func`1<int32>::.ctor(object, native int)
	stsfld class [mscorlib]System.Func`1<int32>
		LazyIntegersInIL.Program::LazyIntegerValueFactoryDelegate
	ldsfld class [mscorlib]System.Func`1<int32>
		LazyIntegersInIL.Program::LazyIntegerValueFactoryDelegate
	newobj instance void class
		[mscorlib]System.Lazy`1<int32>::.ctor(
	class [mscorlib]System.Func`1<!0>)
	stloc.0
	call class [mscorlib]System.IO.TextWriter
		[mscorlib]System.Console::get_Out()
	ldloc.0
	callvirt instance !0 class
		[mscorlib]System.Lazy`1<int32>::get_Value()
	callvirt instance void
		[mscorlib]System.IO.TextWriter::WriteLine(int32)
	ret
}

Если вы хотите создать локальную переменную в методе, необходимо сделать две вещи. Во-первых, вы объявляете ее посредством директивы .locals:

.locals init (
	[0] class [mscorlib]System.Lazy`1<int32> lazyInteger)

В данном случае используется локальная переменная lazyInteger, которая имеет тип Lazy<int>. Заметим, что это локальная переменная в слоте 0. Вам не обязательно нужно указывать местоположение слота (через синтаксис [0]), если вы не хотите; компилятор поставит переменную в следующий свободный слот, если вы не сказали, где она должна быть. Вы можете обратиться к переменным либо по местоположению слота, или по имени. Использование переменных требует кодов операций ldloc и stloc:

stloc.0
call class [mscorlib]System.IO.TextWriter
	[mscorlib]System.Console::get_Out()ldloc.0

Помните, что мы говорили в предыдущем разделе, что мы объясним эти части .0 кодов операций? Это означает, что вы хотите сохранить значение в 0ой локальной переменной или достать его из этого места. Вы можете также использовать имя переменной для загрузки и сохранения значений:

stloc lazyInteger
call class [mscorlib]System.IO.TextWriter
	[mscorlib]System.Console::get_Out()ldloc lazyInteger

На данный момент вам может быть интересно, куда идут эти значения. IL - это стеко-ориентированный язык, что обозначает, вы вставляете и извлекаете значения из стека. Поэтому, если вы выполняете инструкцию ldloc.0, берется значение 0-ой локальной переменной и помещается в стек. Использование stloc.0 извлекло бы это значение и сохранило бы его обратно в 0-ой локальной переменной.

Вот простой пример использования стека с некоторыми другими «загружающими» кодами операций. Вставьте два значения в стек, double и long, а затем достаньте значение в локальную переменную:

.locals init (
	[0] class [mscorlib]System.Int64 value)
ldc.r8 35.5
ldc.i8 234
stloc.0

Рисунок 5-4 показывает, как выглядит стек после того, как второе значение помещается в стек, а затем то, что осталось в стеке после выполнения stloc.0. Оставлять именно последнее значение в стеке – это то, чего вы не должны делать, потому что это правило IL: вы не можете оставить метод с чем-то еще в стеке. Это чрезвычайно важно – отслеживать, что находится в стеке, когда вы определяете метод с кодами операций. Довольно легко случайно злоупотребить стеком и получить катастрофические результаты: еще одна веская причина для того, чтобы IL не был встроенным в C# или VB.

Рисунок 5-4: Использование IL стека. После сохранения значения long в переменной, в стеке остается только значение double.

Примечание

Мы поговорим об инструменте, который можно использовать, чтобы раскрыть ошибки в кодах операций, в разделе 5.4.3.

Доступ к полям

В листинге 5-4 можно увидеть, что анонимный метод передается конструктору для Lazy<int>. Компилятор C# в итоге создает метод со сложным названием для хранения реализации этого метода, который называется LazyIntegerValueFactory в листинге 5-1. Этот метод превращается в делегата (который в конечном итоге передается Lazy<int>), и этот делегат хранится в поле. Чтобы создать поле, воспользуйтесь директивой .field:

.field private static class
	[mscorlib]System.Func`1<int32> LazyIntegerValueFactoryDelegate

В зависимости от доступности и потребностей доступа, вы будете использовать ключевые слова как private и static. Для использования поля можно воспользоваться кодами операций ldfld, ldsfld, stfld и stsfld:

stsfld class [mscorlib]System.Func`1<int32>
	LazyIntegersInIL.Program::LazyIntegerValueFactoryDelegate
ldsfld class [mscorlib]System.Func`1<int32>
	LazyIntegersInIL.Program::LazyIntegerValueFactoryDelegate

Дополнительное s в двух кодах операций (ldsfld и stsfld) обозначает, что вы пытаетесь использовать статическое поле. Если вы используете поле уровня экземпляра, вы можете воспользоваться ldfld и stfld. Обратите внимание, что с экземплярными полями у вас должен быть объект, который имеет поле в стеке: вы увидите, как создавать объекты, в следующем разделе.

Создание объектов

Чтобы создать свойLazy<int> с фабричным методом, необходимо вызвать конструктор для класса. Это делается с помощью кода операций newobj:

ldnull
ldftn int32
	LazyIntegersInIL.Program::LazyIntegerValueFactory()
newobj instance void class
	[mscorlib]System.Func`1<int32>::.ctor(object, native int)

Помните, когда вы используете IL, вы вставляете и извлекаете значения в стек. Для вызова любого метода в IL, первое, что вам нужно сделать, это вставить все значения в стек, который будет передан целевому методу (в данном случае, конструктору). Это выглядит немного странным. Вы передаете метод: зачем вам это нужно передавать object или native int? Вкратце, когда вы работаете с делегатами, вам необходимо предоставить объект, для которого определен целевой метод, и указатель функции делегату. В вашем случае, ваш метод является статическим, поэтому у вас нет объекта для ссылки, когда нужно его вызвать. Поэтому, первое, что вы делаете, это вставляете значение null в стек при помощи ldnull. Затем вы получаете указатель функции через ldftn. Наконец, вы можете создать ваш Lazy<int> через newobj.

Резюмируя, вам нужно сделать две вещи, когда вы создаете объект:

  • Вставить значения аргументов в стек в том порядке, в котором они нужны конструктору.
  • Использовать newobj, чтобы вызвать конструктор.

Когда newobj будет создан, все значения аргумента будут извлечены из стека, и новый объект будет в стеке. Вот почему был вызван stsfld, чтобы сохранить новый Lazy<int> в вашем статическом поле.

Вызов методов

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

ldloc.0
callvirt instance !0 class
	[mscorlib]System.Lazy`1<int32>::get_Value()

«Подождите», - скажете вы. Почему локальная переменная Lazy<int> (которая является 0-ой) помещается в стек, чтобы вызвать get_Value(), который не принимает никаких аргументов? И код C#, создавший этот IL, использовал свойство Value, а не get_Value(), так откуда же пришел этот метод? И что с этим странным !0 синтаксисом? Так много вопросов!

Начнем с первого вопроса. Когда вы вызываете метод экземпляра, CLR необходимо знать, для какого объекта вы намерены вызвать метод. Вот почему вы вставляете целевой объект в стек первым. Каждый метод экземпляра принимает ссылку на объект в качестве первого аргумента, который определяет цель. Вы не увидите этого в таких языках как C# или VB (также вы это видите в определении метода в IL), но такое есть. Если бы вы вызывали статический метод, вы бы не вставляли объект первым в стек, потому что нет никакой необходимости указывать цель при работе со статическим методом.

Следующий вопрос заключается в get_Value(). Свойства в C# и VB являются синтаксическим «сахаром» вокруг вызовов методов. C# компилятор генерирует методы get_ [PropertyName] и set_ [PropertyName] для геттера и сеттера свойства. Таким образом, когда вы используете свойство, в действительности, вы вызываете его методы, и именно поэтому вы видите вызов get_Value() в IL в этом примере.

Наконец, текст !0. Всякий раз, когда вы вызываете метод в IL, вы должны включить возвращаемый тип в сигнатуру метода. Это, вероятно, отличается от большинства языков, которые вы использовали, потому что вы прямо не предоставляли возвращаемый тип. Но так оно работает в IL. Интересно в этом правиле то, что вы можете перегрузить методы, основываясь только на разнице в возвращаемом типе, это отмечалось в разделе 5.1.3. Для большинства методов вы должны предоставить имя типа для возвращаемого значения, например [mscorlib]::System.Int32. Но Lazy<T> является дженериком, и get_Value() возвращает тип T. Чтобы указать этот тип, нужно использовать синтаксис !n, где значение n равно положению параметра дженерик типа в объявлении типа или метода. В данном случае есть только одно объявление дженерик типа, T, вот почему использовался 0.

Есть несколько кодов операций для вызовов методов, о которых вы должны быть в курсе. В этом примере вы используете callvirt, потому что вы вызываете метод virtual. Вы можете использовать код операции call, если вы вызываете не-virtual или static метод. Также существует calli, который позволяет вызывать функции, если у вас есть указатель на функцию. calli используется с делегатами или native методом (P/Invoke).

Примечание

Вы можете вызвать virtual метод при помощи call, если хотите. Обратите внимание на разделы 3.19 и 4.2 Partition III (http://mng.bz/qu5U), чтобы получить информацию о том, когда могут и должны быть использованы call и callvirt.

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

В коде из листинга 5-2 нет операторов if или while, но эти ключевые слова управления потоком являются общепринятыми в большинстве языков. Ветвление поддерживается в IL через коды операций break. Например, если вы хотите остановиться на метке в методе, основываясь на том, является ли первый аргумент null, вам нужно сделать следующее:

ldarg.1
brtrue ArgumentWasNotNull
// More IL goes here...
ArgumentWasNotNull: // ...

Как и традиционные языки сборки, IL также позволяет ветвление, основываясь на результатах сравнения значений. Следующий фрагмент кода остановится на метке, если первый аргумент больше второго:

ldarg.1
ldarg.2
bge OneIsBiggerThanTwo:
// More IL code goes here...
OneIsBiggerThanTwo: // ...

Информация о группе этих кодов операций управления потоком доступна: мы советуем вам посетить раздел 3 Partition III, чтобы узнать больше об этих кодах операций.

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

Если вам нужно обработать исключения, вы можете сделать это в IL при помощи синтаксиса, который похож на то, что вы можете увидеть в C#. Вот как вы можете словить DivideByZeroException в IL при помощи блока finally:

.try
{
	.try
	{
	// Math code goes here...
	}
	catch [mscorlib]System.DivideByZeroException
	{
	// Exception handling code goes here...
	}
}
finally
{
// Finally handling code goes here...
}

Возможно, вы удивитесь, узнав, что вы не можете добавить блок finally непосредственно в блок try-catch. Это не работает в IL. Вам нужно обернуть try-catch в try-finally. За исключением этого, правила, с которыми вы привыкли работать в C# или VB, также работают и здесь: например, вы можете иметь несколько блоков catch с блоком try.

Вы также можете ловить типы, которые не наследуются от типа Exception. Поскольку CLR должен поддерживать выполнение кода из языков, которые были написаны с возможностью словить любой тип (как С++), в IL возможно написать блок catch, который словит string или Guid. Но вы не захотите делать этого, если вы не пишете компилятор для C++ для целевого .NET. Ловля типов, которые не наследуются от Exception, не является CLS-компилируемым кодом.

Примечание

CLS означает Common Language Specification. Она определяет базовый набор функций, который должны поддерживать все .NET языки. Вы можете найти более подробную информацию на http://mng.bz/nz4x.

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