понедельник, 25 апреля 2011 г.

call vs. callvirt (часть 1)

В процессе написания заметки про sealed-классы заинтересовался вопросом: «Влияет ли модификатор sealed на генерируемый компилятором IL-код вызова функций?». Любопытство завело меня довольно далеко; в этом посте — результаты моих изысканий на тему использования компилятором C# инструкций call и callvirt в генерируемом IL-коде.

На самом деле, в IL, кроме call и callvirt существует ещё одна инструкция для вызова функций — calli, но она используется для вызова функций неуправляемого (unmanaged) кода, поэтому я её не рассматриваю.
Итак, говорим только про вызовы функций, реализованных на управляемом коде. Выбор у компилятора C# не очень богатый — две инструкции. Как компилятор решает, какую инструкцию использовать? Для этого надо разобраться, в чём разница между call и callvirt:
  • Для вызова статических функций используется только call.
  • При вызове экземплярных методов call не делает проверку экземпляра на null; callvirt же гарантирует, что если метод вызывался у экземпляра, равного null, будет сгенерировано исключение NullReferenceException.
  • Для инструкции call вызываемый метод должен быть известен на момент компиляции (раннее связывание); инструкцию callvirt можно использовать и для вызова метода, известного на момент компиляции, и для вызова метода, известного только на этапе выполнения (позднее связывание).
Давайте рассмотрим примеры вызовов функций и посмотрим, какие инструкции будут использоваться для этих вызовов:
public struct Value
{
    public void Foo() { }
}

public class Base
{
    public virtual void Foo() { }

    public static void Static() { }
}

public sealed class Derived : Base
{
    public sealed override void Foo()
    {
        // [1] call instance void Base::Foo()
        base.Foo();
    }

    public void Bar() { }

    public void FooBar()
    {
        // [2] callvirt instance void Base::Foo()
        Foo();
        
        // [3] call instance void Derived::Bar()
        Bar();
    }
}

class Demo
{
    static void CallMethods()
    {
        var value = new Value();

        // [4] call instance bool Value::IsPositive()
        value.Foo();

        // [5] box Value
        //     call instance class System.Type System.Object::GetType()
        value.GetType();

        // [6] call void Base::Static()
        Base.Static();

        var derived = new Derived();

        // [7] callvirt instance void Derived::Bar()
        derived.Bar();
        // [8] call instance void Derived::Bar()
        new Derived().Bar();

        // [9] callvirt instance void Base::Foo()
        derived.Foo();
        // [10] callvirt instance void Base::Foo()
        new Derived().Foo();
    }
}
Разберём каждый вызов:
  1. this.base.Foo() — вызов метода базового класса из метода наследника.
    Нужен невиртуальный вызов виртуальной функции, поэтому компилятор использует инструкцию call (callvirt привёл бы к рекурсии). Поскольку метод вызывается из метода наследника, проверка на null не нужна (тот факт, что метод экземпляра был вызван, гарантирует что this не равен null).
  2. this.Foo() — вызов виртуального метода своего класса.
    Нужен виртуальный вызов виртуальной функции, поэтому компилятор использует инструкцию callvirt. Теоретически компилятор мог бы в данном случае использовать call (call instance void Derived::Foo()), т.к. у класса Derived нет наследников и this не равен null. Но принимая решение о том какую инструкцию использовать, компилятор не учитывает модификаторы sealed ни у класса, ни у вызываемого метода.
  3. this.Bar() — вызов невиртуального метода своего класса.
    Виртуальный вызов не нужен, проверка this на null — тоже, поэтому компилятор использует инструкцию call.
  4. value.Foo() — вызов невиртуального метода value-типа.
    Виртуальный вызов не нужен, проверка на null — тоже (значение value-типа не может быть null), поэтому компилятор использует инструкцию call.
  5. value.GetType() — вызов невиртуального метода reference-типа (класс Object) через экземпляр value-типа.
    Т.к. метод невиртуальный и определён в reference-типе, необходим боксинг (boxing) значения value-типа. Ссылка, получившаяся в результате боксинга, гарантированно не равна null; виртуальный вызов не нужен, поэтому компилятор использует инструкцию call.
  6. Base.Static() — вызов статического метода.
    Невиртуальный вызов, на null проверять нечего, компилятор использует инструкцию call.
  7. derived.Bar() — вызов невиртуального метода.
    Нужен невиртуальный вызов, но компилятор не знает, что ссылка derived не равна null, поэтому использует инструкцию callvirt.
  8. new Derived().Bar() — вызов невиртуального метода.
    Почти полный аналог п. 7, за исключением того что созданный экземпляр Derived перед вызовом в переменную не сохраняется. Это маленькое отличие даёт компилятору уверенность, что метод вызывается у экземпляра гарантированно не равного null, поэтому он использует инструкцию call.
  9. derived.Foo() — вызов виртуального метода.
    Нужен виртуальный вызов и проверка на null, поэтому компилятор использует инструкцию callvirt.
  10. new Derived().Foo() — вызов виртуального метода.
    Хотя (как и в п. 8) проверка на null не нужна, нужен виртуальный вызов, поэтому компилятор использует инструкцию callvirt. Опять же, как и в п. 2, можно было бы использовать call, но и здесь наличие модификаторов sealed не учитывается.
Инструкция callvirt может предваряться префиксом constrained:
public struct Value
{
    public override string ToString() { return ""; }
}

class Demo
{
    private static void CallMethods()
    {
        var value = new Value();

        // [1]
        // constrained Value
        // callvirt instance string System.Object::ToString()
        value.ToString();

        // [2]
        // constrained Value
        // callvirt instance int32 System.Object::GetHashCode()
        value.GetHashCode();
    }

    static void NonConstrainedGeneric<T>(T value)
    {
        // [3]
        // constrained !!T
        // callvirt instance string System.Object::ToString()
        value.ToString();
    }

    static void ConstrainedGeneric<T>(T value) where T : class
    {
        // [4]
        // constrained !!T
        // callvirt instance string System.Object::ToString()
        value.ToString();
    }
}
В этом примере во всех четырёх случаях используется инструкция callvirt с префиксом constrained; аргумент constrained задаёт ограничение для типа, метод которого вызывается. Префикс constrained позволяет использовать инструкцию callvirt для единообразного вызова методов как reference-типов, так и value-типов:
  1. value.ToString() — вызов виртуального метода, определённого в reference-типе и переопределённого в value-типе Value.
    Здесь сначала будет получен указатель на value, затем для этого указателя будет выполнен вызов метода Value::ToString инструкцией call. Компилятор (как и в случае с sealed-классами) мог бы генерировать инструкцию call, но не делает этого.
  2. value.GetHashCode() — вызов виртуального метода, определённого в reference-типе, переопределённого в типе ValueType и непереопределённого в value-типе Value.
    Здесь сначала будет осуществлён боксинг значения value, затем для получившейся ссылки будет выполнен вызов метода Object::GetHashCode инструкцией callvirt.
  3. T value.ToString() — вызов виртуального метода для экземпляра типа T, о котором на момент компиляции ничего неизвестно.
    Здесь всё зависит от типа value, который будет известен только на этапе выполнения. Если value будет экземпляром value-типа, вызов ToString будет выполнен, аналогично пп. 1 и 2. Если value будет экземпляром reference-типа, будет выполнен вызов метода Object::ToString инструкцией callvirt.
  4. T value.ToString() where T : class — вызов виртуального метода для экземпляра типа T, о котором на момент компиляции известно, что он является reference-типом.
    Здесь можно было бы вполне обойтись без префикса constrained, т.к. тип T value-типом быть не может. Однако компилятор не учитывает ограничения, наложенные на generic-параметр.
По сути, префикс constrained можно рассматривать как макрос, который позволяет перенести работу по генерации инструкций вызова с этапа компиляции на этап выполнения.
Из приведённых примеров видно, что компилятор C# генерирует не самый оптимальный IL-код. Но, на самом деле, ничего страшного в этом нет и вот почему:
  • IL-код не предназначен для выполнения, ему не обязательно быть оптимальным. Это абстрактные инструкции абстрактной машины; в исполняемый код их превращает JIT-компилятор. Поэтому в первую очередь IL-код должен быть удобным для JIT-компиляции.
  • Чем проще компилятор, тем проще его реализовать, тестировать и поддерживать.
  • Чем проще компилятор, тем выше скорость его работы.
  • Помимо компилятора C#, существуют другие компиляторы, которые генерируют IL-код. Поэтому оптимизацию выгоднее производить на уровне JIT-компилятора, от неё выиграют сразу все программы на IL-коде, а не только написанные на C#.

Продолжение здесь

7 комментариев:

  1. не понятен комментарий к первому constraint-примеру."......Здесь сначала будет получен указатель на value, затем для этого указателя будет выполнен вызов метода Value::ToString инструкцией call....." Будет call или callvirt?

    ОтветитьУдалить
    Ответы
    1. Комментарий описывает как будет происходить выполненение кода. В il-коде будет инструкция callvirt с префиксом constrained, но jit-компилятор сгенерирует невиртуальный вызов (call).

      Удалить
  2. "value.GetHashCode() — вызов виртуального метода, определённого в reference-типе, переопределённого в типе ValueType и непереопределённого в value-типе Value."

    То есть я так понял если мы бы не преопределили метод ToString() то value.ToString() тут бы была упаковка?

    ОтветитьУдалить
    Ответы
    1. >>>То есть я так понял если мы бы не преопределили метод ToString() то value.ToString() тут бы была упаковка?
      Да

      Удалить
  3. Не могли бы вы немного подробнее объяснить префикс constrained. Для чего он нужен? Каки образом он унифицирует вызов для ref и val типов? И в каких случаях компилятор его вставляет в IL?

    ОтветитьУдалить
  4. public void FooBar()
    {
    // [2] callvirt instance void Base::Foo()
    Foo();

    сбило с толку! Как-будто Derived::Foo() не вызывается...

    ОтветитьУдалить
  5. Годная статья, спасибо!

    ОтветитьУдалить