Call и Callvirt

В чем разница между инструкциями CIL "Позвонить" и "Callvirt"?

6 ответов

Решение

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

Когда среда выполнения выполняет call инструкция это вызов точного куска кода (метода). Там нет вопроса о том, где он существует. После того, как IL был JITted, результирующий машинный код на сайте вызова является безусловным jmp инструкция.

В отличие от callvirt Инструкция используется для вызова виртуальных методов полиморфным способом. Точное местоположение кода метода должно быть определено во время выполнения для каждого вызова. Результирующий код JITted включает некоторую косвенность через структуры vtable. Следовательно, вызов выполняется медленнее, но он более гибок в том смысле, что допускает полиморфные вызовы.

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

sealed class SealedObject : object
{
   public override bool Equals(object o)
   {
      // ...
   }
}

Рассмотрим код вызова:

SealedObject a = // ...
object b = // ...

bool equal = a.Equals(b);

В то время как System.Object.Equals(object) это виртуальный метод, в этом использовании нет способа для перегрузки Equals метод существовать. SealedObject является запечатанным классом и не может иметь подклассов.

По этой причине.NET sealed классы могут иметь лучшую производительность диспетчеризации методов, чем их незапечатанные аналоги.

РЕДАКТИРОВАТЬ: Оказывается, я был не прав. Компилятор C# не может сделать безусловный переход к местоположению метода, потому что ссылка на объект (значение this в методе) может быть нулевым. Вместо этого он излучает callvirt который выполняет нулевую проверку и выбрасывает, если требуется.

Это фактически объясняет некоторый причудливый код, который я нашел в.NET Framework с помощью Reflector:

if (this==null) // ...

Компилятор может выдавать проверяемый код, который имеет нулевое значение для this указатель (local0), только csc не делает этого.

Так что я думаю call используется только для статических методов и структур класса.

Учитывая эту информацию, мне кажется, что sealed полезно только для безопасности API. Я обнаружил еще один вопрос, который, по-видимому, говорит о том, что нет никаких преимуществ в производительности для запечатывания ваших классов.

РЕДАКТИРОВАТЬ 2: Есть больше, чем кажется. Например, следующий код испускает call инструкция:

new SealedObject().Equals("Rubber ducky");

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

Интересно, что в сборке DEBUG следующий код выдает callvirt:

var o = new SealedObject();
o.Equals("Rubber ducky");

Это потому, что вы можете установить точку останова во второй строке и изменить значение o, В сборках релизов я думаю, что вызов будет call скорее, чем callvirt,

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

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

К сожалению, это не случай. Callvirt делает еще одну вещь, которая делает его полезным. Когда у объекта есть вызываемый метод, callvirt проверит, существует ли объект, и если нет, выдает исключение NullReferenceException. Вызов просто перейдет в область памяти, даже если ссылка на объект отсутствует, и попытается выполнить байты в этом месте.

Это означает, что callvirt всегда используется компилятором C# (не уверен насчет VB) для классов, а call всегда используется для структур (потому что они никогда не могут быть нулевыми или иметь подклассы).

Редактировать В ответ на комментарий Drew Noakes: Да, кажется, вы можете заставить компилятор отправлять вызов для любого класса, но только в следующем очень специфическом случае:

public class SampleClass
{
    public override bool Equals(object obj)
    {
        if (obj.ToString().Equals("Rubber Ducky", StringComparison.InvariantCultureIgnoreCase))
            return true;

        return base.Equals(obj);
    }

    public void SomeOtherMethod()
    {
    }

    static void Main(string[] args)
    {
        // This will emit a callvirt to System.Object.Equals
        bool test1 = new SampleClass().Equals("Rubber Ducky");

        // This will emit a call to SampleClass.SomeOtherMethod
        new SampleClass().SomeOtherMethod();

        // This will emit a callvirt to System.Object.Equals
        SampleClass temp = new SampleClass();
        bool test2 = temp.Equals("Rubber Ducky");

        // This will emit a callvirt to SampleClass.SomeOtherMethod
        temp.SomeOtherMethod();
    }
}

ПРИМЕЧАНИЕ Класс не должен быть запечатан, чтобы это работало.

Таким образом, похоже, что компилятор отправит вызов, если все это правда:

  • Вызов метода происходит сразу после создания объекта.
  • Метод не реализован в базовом классе

По данным MSDN:

Звоните:

Инструкция call вызывает метод, указанный дескриптором метода, переданным вместе с инструкцией. Описатель метода - это токен метаданных, который указывает метод для вызова... Маркер метаданных несет в себе достаточно информации, чтобы определить, является ли вызов статическим методом, методом экземпляра, виртуальным методом или глобальной функцией. Во всех этих случаях адрес назначения полностью определяется из дескриптора метода (в отличие от инструкции Callvirt для вызова виртуальных методов, где адрес назначения также зависит от типа времени выполнения ссылки на экземпляр, помещенной перед Callvirt).

CallVirt:

Инструкция callvirt вызывает метод с поздним связыванием для объекта. Таким образом, метод выбирается на основе типа времени выполнения obj, а не класса времени компиляции, видимого в указателе метода. Callvirt может использоваться для вызова как виртуальных методов, так и методов экземпляра.

Таким образом, в основном, для вызова метода экземпляра объекта используются разные маршруты, переопределенные или нет:

Вызов: переменная -> объект типа переменной -> метод

CallVirt: переменная -> экземпляр объекта -> объект типа объекта -> метод

Возможно, стоит добавить одну вещь к предыдущим ответам: кажется, что существует только одно лицо, как на самом деле выполняется "IL call", и два лица, как исполняется "IL callvirt".

Возьмите этот пример настройки.

    public class Test {
        public int Val;
        public Test(int val)
            { Val = val; }
        public string FInst () // note: this==null throws before this point
            { return this == null ? "NO VALUE" : "ACTUAL VALUE " + Val; }
        public virtual string FVirt ()
            { return "ALWAYS AN ACTUAL VALUE " + Val; }
    }
    public static class TestExt {
        public static string FExt (this Test pObj) // note: pObj==null passes
            { return pObj == null ? "NO VALUE" : "VALUE " + pObj.Val; }
    }

Во-первых, тело CIL FInst() и FExt() на 100% идентично, код операции-к-оператору (за исключением того, что один объявлен как "экземпляр", а другой - как "статический") - однако FInst() будет вызываться с callvirt и FExt() с вызовом.

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

Вот что примерно происходит после JITting:

    pObj.FExt(); // IL:call
    mov         rcx, <pObj>
    call        (direct-ptr-to) <TestExt.FExt>

    pObj.FInst(); // IL:callvirt[instance]
    mov         rax, <pObj>
    cmp         byte ptr [rax],0
    mov         rcx, <pObj>
    call        (direct-ptr-to) <Test.FInst>

    pObj.FVirt(); // IL:callvirt[virtual]
    mov         rax, <pObj>
    mov         rax, qword ptr [rax]  
    mov         rax, qword ptr [rax + NNN]  
    mov         rcx, <pObj>
    call        qword ptr [rax + MMM]  

Единственная разница между "call" и "callvirt[instance]" заключается в том, что "callvirt[instance]" намеренно пытается получить доступ к одному байту из * pObj, прежде чем вызовет прямой указатель на функцию экземпляра (для того, чтобы, возможно, вызвать исключение) прямо там и тогда ").

Таким образом, если вы раздражены тем, сколько раз вы должны написать "проверяющую часть"

var d = GetDForABC (a, b, c);
var e = d != null ? d.GetE() : ClassD.SOME_DEFAULT_E;

Вы не можете нажать "if (this==null) return SOME_DEFAULT_E;" вниз в сам ClassD.GetE() (так как семантика "IL callvirt[instance]" запрещает вам это делать), но вы можете свободно вставить его в.GetE (), если вы переместите.GetE () в функцию расширения где-нибудь (поскольку семантика "IL call" позволяет это сделать - но, увы, потеря доступа к частным пользователям и т. д.)

Тем не менее, выполнение "callvirt[instance]" имеет больше общего с "call", чем с "callvirt [virtual]", поскольку последнему, возможно, придется выполнить тройное косвенное обращение, чтобы найти адрес вашей функции. (косвенное указание на typedef base, затем на base-vtab-or-some-interface, затем на фактический слот)

Надеюсь, это поможет, Борис

Просто добавляя к вышеупомянутым ответам, я думаю, что изменение было сделано давно, так что инструкция Callvirt IL будет сгенерирована для всех методов экземпляра, а инструкция Call IL будет сгенерирована для статических методов.

Ссылка:

Многоплановый курс "C# Language Internals. Часть 1" от Барта де Смета (видео - Инструкции по вызову и стеки вызовов в CLR IL в двух словах)

а также https://blogs.msdn.microsoft.com/ericgu/2008/07/02/why-does-c-always-use-callvirt/

Другие вопросы по тегам