Call et Callvirt

Quelle est la différence entre les instructions CIL “Call” et “Callvirt”?

call sert à appeler des méthodes non virtuelles, statiques ou superclasses, c.-à-d. que la cible de l’appel n’est pas soumise à la priorité. callvirt d’appeler des méthodes virtuelles (de sorte que s’il s’agit d’une sous-classe qui remplace la méthode, la version de sous-classe est appelée à la place).

Lorsque le runtime exécute une instruction d’ call , il appelle un morceau de code exact (méthode). Il n’y a pas de doute sur l’endroit où il existe. Une fois que l’IL a été JITted, le code machine résultant sur le site d’appel est une instruction jmp inconditionnelle.

En revanche, l’instruction callvirt est utilisée pour appeler des méthodes virtuelles de manière polymorphe. L’emplacement exact du code de la méthode doit être déterminé à l’exécution pour chaque appel. Le code JITted résultant implique une indirection via des structures vtable. Par conséquent, l’appel est plus lent à exécuter, mais il est plus flexible car il permet des appels polymorphes.

Notez que le compilateur peut émettre call instructions d’ call pour les méthodes virtuelles. Par exemple:

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

Envisagez d’appeler le code:

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

Bien que System.Object.Equals(object) soit une méthode virtuelle, dans cette utilisation, il n’existe aucun moyen de surcharger la méthode Equals . SealedObject est une classe scellée et ne peut pas avoir de sous-classes.

Pour cette raison, les classes sealed de .NET peuvent avoir une meilleure répartition des performances que leurs homologues non scellées.

EDIT: Il s’est avéré que j’avais tort. Le compilateur C # ne peut pas effectuer un saut inconditionnel vers l’emplacement de la méthode car la référence de l’object (la valeur de this dans la méthode) peut être nulle. Au lieu de cela, il émet callvirt qui effectue la vérification null et lève si nécessaire.

Cela explique en fait un code bizarre trouvé dans le framework .NET en utilisant Reflector:

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

Il est possible pour un compilateur d’émettre du code vérifiable qui a une valeur nulle pour le pointeur this (local0), seul csc ne le fait pas.

Je suppose donc que call est uniquement utilisé pour les méthodes et les structures statiques de classe.

Compte tenu de cette information, il me semble maintenant que sealed est uniquement utile pour la sécurité API. J’ai trouvé une autre question qui semble suggérer qu’il n’y a pas d’avantages à sceller vos cours.

EDIT 2: Il y a plus à cela qu’il n’y paraît. Par exemple, le code suivant émet une instruction d’ call :

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

Évidemment, dans un tel cas, il n’y a aucune chance que l’instance d’object soit nulle.

Fait intéressant, dans une version de DEBUG, le code suivant émet callvirt :

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

C’est parce que vous pouvez définir un point d’arrêt sur la deuxième ligne et modifier la valeur de o . Dans les versions release, j’imagine que l’appel serait un call plutôt qu’un callvirt .

Malheureusement, mon PC est actuellement hors service, mais j’expérimenterai une fois que ce sera à nouveau.

Pour cette raison, les classes scellées de .NET peuvent avoir une meilleure répartition des performances que leurs homologues non scellées.

Malheureusement, ce n’est pas le cas. Callvirt fait une autre chose qui le rend utile. Lorsqu’un object a une méthode appelée, callvirt vérifie si l’object existe et, si ce n’est pas le cas, lance une exception NullReferenceException. L’appel sautera simplement à l’emplacement de mémoire même si la référence d’object n’est pas là et essaye d’exécuter les octets à cet endroit.

Cela signifie que callvirt est toujours utilisé par le compilateur C # (pas sûr de VB) pour les classes, et call est toujours utilisé pour les structures (car elles ne peuvent jamais être nulles ou sous-classées).

Modifier En réponse au commentaire de Drew Noakes: Oui, il semble que le compilateur puisse émettre un appel pour n’importe quelle classe, mais uniquement dans les cas très spécifiques suivants:

 public class SampleClass { public override bool Equals(object obj) { if (obj.ToSsortingng().Equals("Rubber Ducky", SsortingngComparison.InvariantCultureIgnoreCase)) return true; return base.Equals(obj); } public void SomeOtherMethod() { } static void Main(ssortingng[] 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(); } } 

REMARQUE La classe n’a pas besoin d’être scellée pour que cela fonctionne.

Il semble donc que le compilateur émettra un appel si toutes ces choses sont vraies:

  • L’appel de méthode est immédiatement après la création de l’object
  • La méthode n’est pas implémentée dans une classe de base

Selon MSDN:

Appel :

L’instruction d’appel appelle la méthode indiquée par le descripteur de méthode passé avec l’instruction. Le descripteur de méthode est un jeton de métadonnées qui indique la méthode à appeler … Le jeton de métadonnées contient des informations suffisantes pour déterminer si l’appel concerne une méthode statique, une méthode d’instance, une méthode virtuelle ou une fonction globale. Dans tous ces cas, l’adresse de destination est entièrement déterminée par le descripteur de méthode (contrastez avec l’instruction Callvirt pour appeler des méthodes virtuelles, où l’adresse de destination dépend également du type d’exécution de la référence d’instance poussée avant Callvirt).

CallVirt :

L’instruction callvirt appelle une méthode tardive sur un object. C’est-à-dire que la méthode est choisie en fonction du type d’exécution d’obj plutôt que de la classe de compilation visible dans le pointeur de méthode . Callvirt peut être utilisé pour appeler à la fois les méthodes virtuelles et les méthodes d’instance.

Donc, fondamentalement, différentes routes sont utilisées pour invoquer la méthode d’instance d’un object, surcharger ou non:

Appel: variable -> object de type variable -> méthode

CallVirt: variable -> instance d’object -> object type d’object -> méthode

Une chose qui mérite peut-être d’être ajoutée aux réponses précédentes est qu’il n’ya qu’un seul visage sur la façon dont «l’appel IL» est réellement exécuté, et deux visages sur la façon dont «IL callvirt» s’exécute.

Prenez cette configuration exemple.

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

Tout d’abord, le corps CIL de FInst () et FExt () est identique à 100%, opcode-à-opcode (sauf que l’un est déclaré “instance” et l’autre “static”) – cependant, FInst () sera appelé avec “callvirt” et FExt () avec “call”.

Deuxièmement, FInst () et FVirt () seront tous deux appelés avec «callvirt» – même si l’un est virtuel mais pas l’autre – mais ce n’est pas le «même callvirt» qui sera réellement exécuté.

Voici ce qui se passe approximativement après 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 seule différence entre “call” et “callvirt [instance]” est que “callvirt [instance]” tente intentionnellement d’accéder à un octet à partir de * pObj avant d’appeler le pointeur direct de la fonction d’instance (pour éventuellement lancer une exception) juste là et ensuite “).

Ainsi, si vous êtes ennuyé par le nombre de fois que vous devez écrire la “partie de vérification” de

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

Vous ne pouvez pas pousser “if (this == null) return SOME_DEFAULT_E;” vers le bas dans ClassD.GetE () lui-même (comme la sémantique “IL callvirt [instance]” vous interdit de le faire) mais vous êtes libre de le pousser dans .GetE () si vous déplacez .GetE () vers une fonction d’extension quelque part (comme le permet la sémantique “appel IL” – mais hélas, perdre l’access aux membres privés etc.)

Cela dit, l’exécution de “callvirt [instance]” a plus en commun avec “call” qu’avec “callvirt [virtual]”, car ce dernier peut avoir à exécuter une sortingple indirection pour trouver l’adresse de votre fonction. (indirection vers typedef base, puis vers base-vtab-or-some-interface, puis vers l’emplacement réel)

J’espère que ça aide, Boris

En ajoutant simplement aux réponses ci-dessus, je pense que le changement a été fait depuis longtemps, de sorte que les instructions Callvirt IL seront générées pour toutes les méthodes d’instance et les instructions Call IL seront générées pour les méthodes statiques.

Référence :

Cours Pluralsight “Internals du langage C # – Partie 1 par Bart De Smet (vidéo – Instructions d’appel et stacks d’appels dans CLR IL en bref)

et aussi https://blogs.msdn.microsoft.com/ericgu/2008/07/02/why-does-c-always-use-callvirt/