Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
На сегодняшний день в .NET существует несколько видов кодогенерации: новомодные Source Generators, компилируемые Expression Trees, динамические сборки и динамические методы. Каждый способ имеет свои области применения, плюсы и минусы.
В этой статье я хочу рассказать про динамические методы. Мы разберёмся как создавать их, как работает стековая машина и рассмотрим некоторые базовые операции Common Intermediate Language: работу с константами, математические и битовые операции, а также работу с аргументами методов и локальными переменными.
Сразу хочу сказать, что статья не содержит какого-то хардкора и написана в большей степени для новичков. Если к теме будет интерес, постараюсь в следующей статье рассмотреть более сложные примеры (эта и так получилась достаточно длинной).
Пара общих слов про динамические методы
Итак, динамические методы позволяют прямо во время выполнения программы сгенерировать, скомпилировать и выполнить любой код. Причём работать он будет также быстро как и обычный, без накладных расходов на использование метаданных, как это обычно бывает при использовании reflection.
Пишутся динамические методы при помощи команд специального высокоуровневого ассемблера, который называется Common Intermediate Language (далее просто IL). Чтобы в общих чертах понять как это выглядит достаточно взять ILSpy и открыть какую-нибудь сборку, например:
Так выглядит метод JsonSerializer.Deserialize<T>
из популярной библиотеки Newtonsoft.Json. Ключевые слова ldarg
, call
, unbox
и ret
- всё это операции IL, которые вместе со своими аргументами формируют команды.
Минимальный динамический метод
Сразу перейдём от теории к практике и создадим самый простой динамический метод, который просто выводит сообщение на консоль.
Для этого нам понадобятся пространство имён System.Reflection.Emit
, классы DynamicMethod
и ILGenerator
, а также статический класс OpCodes
, содержащий статические свойства для всех доступных в IL операций:
Прежде всего мы создаём экземпляр класса DynamicMethod
указывая несколько параметров:
name
- Название метода. Может быть любым и может содержать произвольные символы. Полезно для отладки. Если лень придумывать - можно просто передать туда пустую строку.returnType
- тип возвращаемого значения, в нашем случае метод ничего не возвращает.parameterTypes
- массив с типами параметров, в нашем случае метод не имеет параметров.restrictedSkipVisibility
- достаточно полезный параметр, который позволяет динамическому методу получать доступ кprivate
илиprotected
членам классов, чего нельзя сделать в обычном коде.
Отдельно отмечу, что последний параметр часто используется при генерации различных мапперов и является особенностью именно динамических методов. Динамические сборки и компилируемые Expression Trees не имеют такой возможности.
Далее мы через метод GetILGenerator
получаем экземпляр ILGenerator
и используем метод Emit
для формирования тела метода. В качестве параметра этот метод получает код операции, который можно найти в статическом классе OpCodes
.
Метод EmitWriteLine
является вспомогательным. Внутри себя он генерирует другие команды, которые вызывают методы класса Console
, и обычно используется для отладки.
Любой динамический метод должен завершаться командой ret
, которая возвращает управление. Это единственная команда, которая всегда должна присутствовать хотя бы один раз. В принципе, можно вернуть управление и из середины метода, но в конце всё равно должна быть командаret
.
В самом конце мы используем метод CreateDelegate
экземпляра динамического метода, чтобы создать экземпляр делегата и затем выполнить его. Результатом выполнения такой программы станет:
Мы только что создали и выполнили свой первый динамический метод!
Стековая машина
IL работает по принципу стековой машиной. Это означает, что каждый метод имеет некоторый объём памяти, называемый стеком, куда можно помещать значения, а потом извлекать их. (Т.е. тут нет никаких регистров процессора и прочих сложностей.)
При выполнении команд IL (ну да, как бы понятно, что на самом деле код на IL не выполняется, а компилируется JIT компилятором в машинный код, который уже потом выполняется, но это не так важно) каждая команда может сначала извлечь из стека несколько значений (а может и ничего не извлекать, часто извлекаются 1 или 2 значения), выполнить какие-то преобразования и, получив результат операции, поместить этот результат обратно в стек (а может и ничего не поместить в стек).
Например, предположим, что у нас в куче хранится информация о пользователе с двумя полями FirstName и LastName, и мы хотим вычислить полное имя пользователя, объединив эти два поля через пробел.
Мы можем сделать это (упрощённо) путём последовательных команд на стеке, таких как "загрузить ссылку на пользователя на стек", "загрузить значение поля на стек", "загрузить строку на стек", "конкатенировать строки на стеке" и т.п. Выглядит это примерно так:
Тут важно несколько особенностей:
размер стека каждого метода фиксирован (часто несколькими десятками байт), стек не может расти бесконечно;
для каждой операции жёстко определено сколько значений она извлекает из стека и сколько возвращает, это значение не может меняться динамически (хотя при вызове методов количество извлекаемых значений всё же определяется количеством параметров вызываемого метода);
перед возвратом управления (т.е. перед командой
ret
) на стеке должно остаться только одно возвращаемое значение или ничего, если метод ничего не возвращает. Формально, командаret
извлекает возвращаемое значение из стека, поэтому после её выполнения стек всегда должен становиться пустым.
Операции с константами
Самое простое, что можно сделать в IL - это загрузить на стек константу (например, чтобы потом вернуть её из метода). Есть несколько операций, которые это делают:
ldnull | загружает на стек значение null |
ldstr | загружает на стек ссылку указанную константную строку |
ldc.i4.m1 | загружает на стек число -1 (4 байта) |
ldc.i4.0 | загружают на стек числа от 0 до 8 (4 байта) |
ldc.i4.s | загружают на стек целые числа, указанные в аргументе (4 байта) |
ldc.i8 | загружают на стек целые числа, указанные в аргументе (8 байт) |
ldc.r4 | загружают на стек дробные числа, указанные в аргументе (4 байта) |
ldc.r8 | загружают на стек целые числа, указанные в аргументе (8 байт) |
Например, динамический метод, который просто возвращает ответ на главный вопрос жизни, вселенной и всего такого, будет выглядеть так:
Тут есть несколько нюансов:
Во-первых, в IL у многих операций есть сокращённые формы, которые используются для уменьшения размера байт-кода. Например, три следующих команды делают одно и тоже (загружают на стек число 5):
При этом первая команда занимает 1 байт (только код операции, без аргумента), вторая - 2 байта (1 байт для кода операции и 1 байт для аргумента), а третья - 5 байт (1 байт для кода операции и 4 байта для аргумента). Поскольку такие константы как 0 или 1 в коде могут встречаться довольно часто, сокращённые формы позволяют несколько уменьшить размер сборок.
Во-вторых, на стек можно загрузить только значения размером 4 или 8 байт. Числа меньшей разрядности, такие как byte
или short
, всегда расширяются и хранятся на стеке как 4 байта.
Во-третьих, значения на стеке в некоторых случаях вообще никак не типизируются и воспринимаются просто как набор байтов. Формально можно загрузить на стек дробное число Math.PI
, а потом вернуть его из метода как long
и это будет работать.
Даже такой код (прости, господи) будет работать:
Операция ldstr
загружает на стек ссылку на константную строку. Эта ссылка имеет размер 8 байт (на 64 битной системе). И double
тоже имеет размер 8 байт (какое совпадение), поэтому можно вернуть значение ссылки, как будто это дробное число:
Математические и битовые операции
Научившись загружать на стек числа было бы неплохо научиться делать с ними что-нибудь. Для этого в IL есть множество операций которые условно можно поделить на арифметические и битовые:
add | арифметические операции: сложение, вычитание, умножение, деление, получение остатка от деления и проверка того, что число не является бесконечностью |
and | битовые операции: и, или, исключающее или, инверсия битов, не и три операции битового сдвига |
Все команды с данными операциями работают одинаково: извлекают из стека одно или два значения, производят вычисления и помещают результат обратно на стек.
Так, например, можно сложить два дробных числа:
Тут мы сначала помечаем два числа на стек, извлекаем их операцией add
(стек в этот момент становится пустым), после чего результат этой операции возвращается на стек, а затем возвращается из метода операцией ret
.
При этом, например, операцией add
можно складывать как целые, так и дробные числа. Допустимы даже некоторые комбинации типов, которые можно найти в документации по конкретной операции.
В таблице выше можно также заметить, что некоторые операции имеют вариации с суффиксами .ovf
и .un
.
С суффиксом .ovf
всё просто. Это операции, которые выбрасывают OverflowException
, если в результате выполнения команды происходит переполнение.
С суффиксом .un
немного интереснее. Поскольку, как я писал выше, значения на стеке не типизируются, то при выполнении каждой операции нужно понимать, с каким числом мы работаем: со знаком или без знака. И это знание "зашито" в код операции.
Например, если мы загрузим на стек два значения (со знаком): Int32.MaxValue
и 1
, а потом попытаемся их сложить операцией add.ovf.un
(т.е. считая, что складываем беззнаковые числа), то у нас всё получится, т.к. переполнения в этом случае не происходит:
А если попытаемся сделать тоже самое при помощи операции add.ovf
(т.е. считая, что складываем числа со знаком), то получим исключение, т.к. произойдёт переполнение:
Операции для работы с аргументами метода
Складывать константы, конечно интересно, но пользы от этого мало. При создании динамических методов нам хотелось бы уметь потом передавать в них параметры. Для этого в IL есть операции для работы с аргументами метода:
ldarg | загружает на стек значение параметра метода с указанным индексом |
ldarg.0 | загружает на стек значение 0-ого, 1-ого, 2-ого или 3-его параметра метода; |
ldarga | загружает на стек адрес значения метода с указанным индексом; |
starg | извлекает значение из стека и сохраняет его в параметр метода с указанным индексом; |
Например, если мы хотим сделать динамический метод, который просто складывает два числа, это можно сделать так:
Нам необходимо указать типы параметров нашего динамического метода. Сделать это необходимо при его объявлении, в параметре parameterTypes
, а также при вызове CreateDelegate
.
В самом методе мы последовательно загружаем значения первого и второго параметров (я использую и сокращённую, и полную форму для примера), а затем просто складываем их.
Операции для работы с локальными переменными метода
Далеко не всегда можно реализовать какие-то вычисления используя исключительно стек. В таких случаях на помощь приходят локальные переменные, для работы с которыми в IL также есть несколько операций:
ldloc | загружает на стек значение локальной переменной с указанным индексом, а также сокращённые формы этой операции |
ldloca | загружает на стек адрес значения локальной переменной с указанным индексом |
stloc | извлекает значение из стека и сохраняет его в локальную переменную с указанным индексом, а также сокращённые формы этой операции |
В целом, работа с локальными переменными очень похожа на работу с аргументами метода, за исключением того, что все локальные переменные нужно дополнительно объявить при помощи метода DeclareLocal
.
Например, вот как можно переписать метод сложения двух чисел, используя локальные переменные:
Здесь мы выполняем сложение в три этапа:
Загружаем первый аргумент и сохраняем его в локальную переменную.
Загружаем локальную переменную и второй аргумент, складываем их и результат снова сохраняем в туже локальную переменную.
Загружаем локальную переменную и возвращаем её в качестве результата работы метода.
Также обращу внимание на то, что если мы точно знаем индекс локальной переменной (в порядке объявления), то можем использовать сокращённые формы операций для их загрузки и сохранения. Если это не так, то можно просто обратиться к свойству LocalIndex
и использовать полную форму.
Безусловно, в данном случае проще было бы просто сложить два числа и вообще не использовать локальные переменные, но этот пример в простой форме демонстрирует их работу.
Заключение
В данной статье я рассказал только про некоторые самые базовые операции, которыми можно пользоваться в динамических методах: работу с константами, математические и битовые операции и работу с аргументами и локальными переменными методов.
В следующей статье постараюсь рассмотреть более сложные примеры: создание объектов, вызов методов, циклы, ветвления и некоторые другие, связанные с динамическими методами темы.
Немного саморекламы
В качестве пет-проекта я делаю telegram-канал, куда из разных мест собираются интересные статьи, связанные с .NET тематикой.
Канал ведётся автоматически скриптами на GitHub Actions. Там нет и никогда не будет рекламы. Буду рад если зайдёте: https://t.me/amazing_dotnet