Llamar y llamar

¿Cuál es la diferencia entre las instrucciones CIL “Llamar” y “Callvirt”?

call es para llamar a métodos no virtuales, estáticos o de superclase, es decir, el objective de la llamada no está sujeto a anulación. callvirt es para llamar a los métodos virtuales (de modo que si this es una subclase que anula el método, en su lugar se llama a la versión de la subclase).

Cuando el tiempo de ejecución ejecuta una instrucción de call , realiza una llamada a una pieza exacta de código (método). No hay dudas sobre dónde existe. Una vez que el IL ha sido JITted, el código de máquina resultante en el sitio de llamada es una instrucción jmp incondicional.

Por el contrario, la instrucción callvirt se usa para llamar a métodos virtuales de forma polimórfica. La ubicación exacta del código del método se debe determinar en el tiempo de ejecución para cada invocación. El código JITted resultante implica algo de indirección a través de estructuras vtable. Por lo tanto, la llamada es más lenta de ejecutar, pero es más flexible ya que permite llamadas polimórficas.

Tenga en cuenta que el comstackdor puede emitir instrucciones de call para métodos virtuales. Por ejemplo:

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

Considera llamar al código:

 SealedObject a = // ... object b = // ... bool equal = a.Equals(b); 

Mientras que System.Object.Equals(object) es un método virtual, en este uso no hay forma de que exista una sobrecarga del método Equals . SealedObject es una clase sellada y no puede tener subclases.

Por este motivo, las clases sealed de .NET pueden tener un mejor rendimiento en el envío de métodos que sus contrapartes no selladas.

EDITAR: Resulta que estaba equivocado. El comstackdor de C # no puede hacer un salto incondicional a la ubicación del método porque la referencia del objeto (el valor de this dentro del método) puede ser nulo. En su lugar, emite callvirt que realiza la comprobación nula y lo lanza si es necesario.

Esto realmente explica algún código extraño que encontré en el framework .NET usando Reflector:

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

Es posible que un comstackdor emita código verificable que tenga un valor nulo para this puntero (local0), solo que csc no lo hace.

Así que supongo que la call solo se usa para métodos y estructuras estáticos de clase.

Dada esta información, ahora me parece que el sealed solo es útil para la seguridad de la API. Encontré otra pregunta que parece sugerir que no hay beneficios de rendimiento para sellar sus clases.

EDIT 2: Hay más en esto de lo que parece. Por ejemplo, el siguiente código emite una instrucción de call :

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

Obviamente, en tal caso no hay posibilidad de que la instancia del objeto sea nula.

Curiosamente, en una comstackción DEBUG, el siguiente código emite callvirt :

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

Esto se debe a que puede establecer un punto de interrupción en la segunda línea y modificar el valor de o . En versiones de lanzamiento, imagino que la llamada sería una call lugar de callvirt .

Desafortunadamente, mi PC está actualmente fuera de acción, pero voy a experimentar con esto una vez que vuelva a funcionar.

Por este motivo, las clases selladas de .NET pueden tener un mejor rendimiento en el envío de métodos que sus contrapartes no selladas.

Desafortunadamente, este no es el caso. Callvirt hace otra cosa que lo hace útil. Cuando un objeto tiene un método invocado, callvirt comprobará si el objeto existe, y si no arroja una NullReferenceException. La llamada simplemente saltará a la ubicación de la memoria incluso si la referencia del objeto no está allí, y tratará de ejecutar los bytes en esa ubicación.

Lo que esto significa es que callvirt siempre es utilizado por el comstackdor C # (no está seguro de VB) para las clases, y la llamada siempre se usa para las estructuras (porque nunca pueden ser nulas o subclasificadas).

Editar En respuesta a Drew Noakes, comenten: Sí, parece que puede hacer que el comstackdor emita una llamada para cualquier clase, pero solo en el siguiente caso muy específico:

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

NOTA La clase no tiene que estar sellada para que esto funcione.

Entonces parece que el comstackdor emitirá una llamada si todo esto es cierto:

  • La llamada al método es inmediatamente después de la creación del objeto
  • El método no está implementado en una clase base

De acuerdo con MSDN:

Llamar

La instrucción de llamada llama al método indicado por el descriptor de método pasado con la instrucción. El descriptor de método es un token de metadatos que indica el método para llamar … El token de metadatos contiene suficiente información para determinar si la llamada es a un método estático, un método de instancia, un método virtual o una función global. En todos estos casos, la dirección de destino se determina completamente desde el descriptor de método (contraste esto con la instrucción Callvirt para llamar a métodos virtuales, donde la dirección de destino también depende del tipo de tiempo de ejecución de la referencia de instancia antes de Callvirt).

CallVirt :

La instrucción callvirt llama a un método de destino tardío en un objeto. Es decir, el método se elige en función del tipo de tiempo de ejecución de obj en lugar de la clase de tiempo de comstackción visible en el puntero del método . Callvirt se puede usar para llamar a métodos virtuales e instancia.

Entonces, básicamente, se toman diferentes rutas para invocar el método de instancia de un objeto, anulado o no:

Llamada: variable -> objeto tipo variable -> método

CallVirt: variable -> instancia de objeto -> objeto tipo objeto -> método

Una cosa que tal vez valga la pena agregar a las respuestas anteriores es que parece que hay una sola cara de cómo se ejecuta realmente “IL Call” y dos caras de cómo se ejecuta “IL callvirt”.

Toma esta configuración de muestra.

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

Primero, el cuerpo CIL de FInst () y FExt () es 100% idéntico, opcode-a-opcode (excepto que uno es declarado “instancia” y el otro “estático”) – sin embargo, se llamará a FInst () “callvirt” y FExt () con “llamada”.

En segundo lugar, se llamará a FInst () y FVirt () con “callvirt”, aunque uno es virtual pero el otro no, pero no es el “mismo callvirt” que realmente se ejecutará.

Esto es lo que sucede después de JITting:

  pObj.FExt(); // IL:call mov rcx,  call (direct-ptr-to)  pObj.FInst(); // IL:callvirt[instance] mov rax,  cmp byte ptr [rax],0 mov rcx,  call (direct-ptr-to)  pObj.FVirt(); // IL:callvirt[virtual] mov rax,  mov rax, qword ptr [rax] mov rax, qword ptr [rax + NNN] mov rcx,  call qword ptr [rax + MMM] 

La única diferencia entre “call” y “callvirt [instance]” es que “callvirt [instancia]” intencionalmente intenta acceder a un byte desde * pObj antes de llamar al puntero directo de la función de instancia (para posiblemente lanzar una excepción) allí mismo y luego “).

Por lo tanto, si está molesto por el número de veces que tiene que escribir la “parte de comprobación” de

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

No puede presionar “if (this == null) return SOME_DEFAULT_E;” hasta ClassD.GetE () en sí (como la semántica “IL callvirt [instance]” te prohíbe hacer esto) pero puedes insertarlo en .GetE () si mueves .GetE () a una función de extensión en algún lugar (como lo permite la semántica “llamada IL”, pero lamentablemente, perder acceso a miembros privados, etc.)

Dicho esto, la ejecución de “callvirt [instancia]” tiene más en común con “llamar” que con “callvirt [virtual]”, ya que este último puede tener que ejecutar un triple indirecto para encontrar la dirección de su función. (direccionamiento indirecto a la base typedef, luego a base-vtab-o-some-interface, luego a la ranura real)

Espero que esto ayude, Boris

Simplemente agregando las respuestas anteriores, creo que el cambio se ha realizado hace mucho tiempo, de modo que la instrucción Callvirt IL se generará para todos los métodos de instancia y la instrucción Call IL se generará para los métodos estáticos.

Referencia:

Curso de Pluralsight “Intersecciones de lenguaje C # – Parte 1” de Bart De Smet (video – Instrucciones de llamadas y llamadas en CLR IL in a Nutshell)

y también https://blogs.msdn.microsoft.com/ericgu/2008/07/02/why-does-c-always-use-callvirt/