Call и Callvirt - PullRequest
       58

Call и Callvirt

56 голосов
/ 11 октября 2008

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

Ответы [ 6 ]

53 голосов
/ 11 октября 2008

Когда среда выполнения выполняет инструкцию 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.

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

48 голосов
/ 11 октября 2008

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

11 голосов
/ 11 октября 2008

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

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

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

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

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();
    }
}

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

Похоже, что компилятор отправит вызов, если все эти вещи верны:

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

По данным MSDN:

Вызов

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

CallVirt

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

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

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

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

2 голосов
/ 28 июня 2015

Возможно, стоит добавить одну вещь к предыдущим ответам: Кажется, есть только одно лицо, как на самом деле выполняется «вызов IL», и две стороны того, как исполняется "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, затем на фактический слот)

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

1 голос
/ 13 марта 2017

Просто добавив к вышеупомянутым ответам, я думаю, что изменение было сделано давно, так что инструкция 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/

...