В предыдущей статье я начал свой рассказ динамические методы.

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

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

Кроме этого поговорим про динамические методы, привязанные к объектам, и рассмотрим возможность вызова одних динамических методов из других.

Операции вызова методов

Скорее всего, при создании динамического метода, вам потребуется вызывать из него другие методы. В Common Intermediate Language (далее просто IL) для вызова методов имеется три операции:

  • call - используется для прямого вызова метода по его дескриптору; может использоваться для вызова как статических методов, так и методов экземпляра;

  • calli - (если я ничего не путаю) используется обычно для вызова unmanaged кода и не будет рассматриваться в данной статье;

  • callvirt - используется для вызова метода через таблицу виртуальных методов.

Рассмотрим операции call и callvirt более подробно.

Вызов статических методов

С вызовом статических методов всё достаточно просто. Предположим, что у нас есть статический класс Greeter со статическим методом SayHello, который выводит приветствие в зависимости от двух параметров: имени и языка:

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

Предположим, мы хотим написать функцию, которая будет создавать динамический метод с "зашитым" в неё константным языком, но с возможностью передавать имя в качестве параметра:

Теперь мы можем создать два метода для приветствия на русском и английском языках и вызвать их:

В результате получим:

Вызов методов экземпляров

С вызовом методов экземпляров всё чуточку интереснее, т.к. для этого можно использовать как операцию call, так и операцию callvirt, и результат будет зависеть от этого выбора.

Предположим, что у нас есть два класса: класс GreeterA с виртуальным методом SayHello и класс GreeterB, переопределяющий этот виртуальный метод.

Если мы хотим вызвать метод экземпляра из нашего динамического метода, мы должны сначала поместить на стек сам экземпляр, на котором хотим вызвать метод, а затем последовательно поместить на стек значения всех его параметров (в этом примере, для простоты, параметров нет), после чего вызвать операцию call или callvirt передав ей в качестве аргумента необходимый метод.

Предположим, мы хотим написать функцию, которая будет создавать динамический метод для вызова метода SayHello:

Обратите внимание, что тут мы всегда ищем метод SayHello именно в базовом классе GreeterA, чтобы иметь возможность вызывать его как на экземпляре самого класса GreeterA, так и на экземпляре производного класса GreeterB.

Теперь, если мы создадим два динамических метода и вызовем их с экземплярами классов GreeterA и GreeterB:

Мы получим следующий результат:

При вызове метода через операцию call происходит невиртуальный вызов метода (т.е. вызов по указателю на метод). При этом всегда вызывается метод того класса, в котором мы этот метод искали (через GetMethod). Напомню, что мы искали метод SayHello именно в базовом классе, поэтому результат не зависит от реального типа экземпляра.

При вызове же метода через операцию callvirt происходит виртуальный вызов метода, который использует таблицу виртуальных методов (т.е. указатель на метод сначала ищется в таблице, которая уникальна для каждого класса, а только потом происходит вызов). При этом вызываться будет метод из того класса, экземпляр которого используется, поэтому для каждого экземпляра был вызван метод из его класса.

Фактически невиртуальный вызов ничем не отличается от вызова статического метода, где первым параметром просто передаётся экземпляр, на котором этот метод вызывается. И отсюда есть одна особенность: с помощью операции call можно вызвать метод экземпляра на пустой ссылке, т.е. this в методе будет равен null!

Например, если попробовать сделать следующее:

Мы получим следующий результат:

Как видно, первый метод сработал, т.к. он не использует ссылку this и ничто не мешает ему выполняться.

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

Динамические методы, привязанные к объекту

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

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

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

На C# подобный код мог бы выглядеть следующим образом:

Вот как этот же код можно реализовать в виде динамического метода:

Кода получилось уже значительно больше, дам некоторые пояснения:

  • При создании экземпляра DynamicMethod мы указываем два параметра, где в первом параметре передаём тип целевого объекта (или попросту тип this).

  • При создании делегата в самом конце мы напротив не указываем тип целевого объекта, а передаём сам объект (getNextValueTarget) в метод CreateDelegate в качестве параметра.

  • При вызове метода TryGetValue последний параметр имеет модификатор out, поэтому мы передаём в него не значение, а ссылку на значение. Получить её можно через локальную переменную операцией ldloca.

  • После вызова метода TryGetValue на стек помещается результат выполнения этого метода. Поскольку он нам не нужен, мы должны удалить его из стека операцией pop, в конце концов стек остался пустым.

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

  • Для того, чтобы сохранить значение обратно в словарь мы должны обратиться к индексатору. Но индексаторы в .NET - это просто свойства. А свойства в .NET - это просто пары методов с префиксами get_ и set_, поэтому вызвать индексатор можно как обычный метод.

В результате выполнения такого кода мы получим следующее:

Вызов одного динамического метода из другого

Ещё одной полезной особенностью динамических методов является то, что одни динамические методы можно вызывать из других. Для этого достаточно использовать операцию call, передав ей в качестве аргумента ссылку на динамический метод (не на делегат).

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

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

При этом сами объекты мы хотим логировать не банальным ToString, а выводить конкатенацию всех свойств с суффиксом Name (далее, назовём это полным именем объекта). При этом динамический метод получения полного имени объекта мы хотели бы потом использовать ещё где-нибудь.

Например, если у нас есть вот такой класс, содержащий два свойства для хранения имени и фамилии:

То метод получения полного имени объекта на C# мог бы выглядеть следующим образом:

(Я специально не использую StringBuilder, т.к. в динамическом методе мы будем генерировать точно такой же код.)

Напишем метод, который для указанного класса будет возвращать нам динамический метод для получения полного имени объекта этого класса:

Мы могли бы использовать этот метод, чтобы создать делегат и вызвать его следующим образом:

Но вместо этого используем его в другим динамическом методе, который на C# будет отдалённо напоминать следующий код:

Всего одна строчка! Но в виде динамического метода кода будет сильно больше:

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

Данный код выведет следующее:

Заключение

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

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

Немного саморекламы

В качестве пет‑проекта я делаю telegram‑канал, куда из разных мест собираются интересные статьи, связанные с.NET тематикой.

Канал ведётся автоматически скриптами на GitHub Actions. Там нет и никогда не будет рекламы. Буду рад если зайдёте: https://t.me/amazing_dotnet

Комментарии (6)


  1. PahanMenski
    30.05.2023 19:47
    +1

    Начиная с C# 9 есть встроенный синтаксис для calli - Function Pointers. Его можно использовать в том числе и для вызова managed кода, но в любом случае требуется unsafe контекст.


  1. impwx
    30.05.2023 19:47
    +1

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


    Ну и листинг кода лучше бы текстом, а не картинкой...


    1. Ordos Автор
      30.05.2023 19:47

      С одной стороны, это скорее дело вкуса. Многие задачи можно решить как с помощью Expression Trees, так и с помощью DynamicMethod. Я, например, уже настолько привык к последним, что код с il.Emit(...) кажется более простым и понятным, особенно если нормально его написать. Но для кого-то скомпилировать лямбду будет проще, чтобы не сильно разбираться с IL и просто сделать, чтобы работало.

      С другой стороны есть несколько причин, когда Expression Trees могут и не подойти:

      • Поскольку Expression Trees внутри себя всё равно используют DynamicMethod, то это всё таки более высокоуровневая штука, которая может не всё уметь. По крайней мере раньше нельзя было нормально сделать цикл, ветвление, объявление переменных (сейчас возможно что-то уже появилось).

      • Только DynamicMethod поддерживает режим skipVisibility, когда игнорируется приватность членов. Это часто используется во всяких мапперах (тот же EF это использует, насколько я помню).

      • Expression Trees, насколько я знаю, нельзя привязать к объекту, как DynamicMethod. Т.е. нельзя сделать делегат с состоянием.


      1. impwx
        30.05.2023 19:47

        Все это можно сделать. Например (листинг из LINQPad):


        void Main()
        {
            var f = new Foo { A = 1337 };
            var func = Expression.Lambda<Func<int>>(
                Expression.Property(Expression.Constant(f), "B")
            );
            func.Compile()().Dump();
        }
        
        class Foo
        {
            public int A {get; set;}
            private int B => A * 2;
        }

        Тут решаются проблемы пунктов 2 и 3 — можно замкнуть произвольное значение "извне" внутри функции с помощью Expression.Constant (это не обязательно должна быть константа времени компиляции, может быть произвольный объект как в примере выше), и к приватному полю также можно обратиться без проблем.


        Чтобы рулить потоком исполнения, есть LoopExpression, SwitchExpression, ConditionalExpression и даже GotoExpression.


        А вот что, кажется, нельзя сделать — это написать unsafe-код. Сделать type punning тоже нельзя, хотя это имхо скорее плюс — случайные ошибки гораздо вероятнее чем то что это кому-то действительно нужно.


        1. Ordos Автор
          30.05.2023 19:47

          Круто! Это действительно работает, я не знал, что Expression Trees так умеют.

          Я ради интереса проверил, что за код там генерится. Там что-то вроде такого:

          ldarg.0
          ldfld ...
          ldc.i4.0
          ldelem.ref
          castclass ...
          callvirt ...
          ret

          Я так понимаю, что он все объекты из Expression.Constant хранит в каком-то массиве, откуда их достаёт по индексу и кастит к нужному типу.

          На том же динамическом методе это можно сделать проще:

          ldarg.0
          callvirt ...
          ret

          Тут уже вопрос в том, насколько вы хотите и любите упарываться с оптимизацией =)


    1. Ordos Автор
      30.05.2023 19:47

      По поводу листинга и картинок. Скажем так, это эксперимент =)

      Я знаю, что листинги можно вставить как текст, но решил намеренно от этого отказаться, т.к. подумал, что:

      • скрин из IDE с привычной расцветкой синтаксиса будет лучше читаться и восприниматься;

      • Можно сразу показать работу кода в дебаггере с результатом.

      Но я учту это на будущее.