Où sont stockées les méthodes génériques?

J’ai lu des informations sur les génériques dans .ΝΕΤ et j’ai remarqué une chose intéressante.

Par exemple, si j’ai une classe générique:

class Foo { public static int Counter; } Console.WriteLine(++Foo.Counter); //1 Console.WriteLine(++Foo.Counter); //1 

Deux classes Foo et Foo sont différentes à l’exécution. Mais qu’en est-il lorsque la classe non générique a une méthode générique?

 class Foo { public void Bar() { } } 

Il est évident qu’il n’y a qu’une seule classe Foo . Mais qu’en est-il de la méthode Bar ? Toutes les classes et méthodes génériques sont fermées à l’exécution avec les parameters qu’elles utilisent. Est-ce que cela signifie que la classe Foo a beaucoup d’implémentations de Bar et où les informations sur cette méthode stockées en mémoire?

Contrairement aux modèles C ++ , les génériques .NET sont évalués à l’exécution, et non à la compilation. Sémantiquement, si vous instanciez la classe générique avec des parameters de type différents, ceux-ci se comporteront comme s’il s’agissait de deux classes différentes, mais sous le capot, il n’y a qu’une seule classe dans le code IL (langage intermédiaire) compilé.

Types génériques

La différence entre différents instantanés du même type générique devient évidente lorsque vous utilisez Reflection : typeof(YourClass) ne sera pas identique à typeof(YourClass) . Ce sont les types génériques construits . Il existe également un typeof(YourClass<>) qui représente la définition de type générique . Voici quelques conseils supplémentaires sur le traitement des génériques via Reflection.

Lorsque vous instanciez une classe générique construite , le moteur d’exécution génère une classe spécialisée à la volée. Il existe des différences subtiles entre son fonctionnement avec les types valeur et référence.

  • Le compilateur ne générera qu’un seul type générique dans l’assembly.
  • Le moteur d’exécution crée une version distincte de votre classe générique pour chaque type de valeur utilisé.
  • Le moteur d’exécution alloue un ensemble distinct de champs statiques pour chaque paramètre de type de la classe générique.
  • Les types de référence ayant la même taille, le moteur d’exécution peut réutiliser la version spécialisée générée la première fois que vous l’avez utilisé avec un type de référence.

Méthodes génériques

Pour les méthodes génériques , les principes sont les mêmes.

  • Le compilateur génère uniquement une méthode générique, qui est la définition de la méthode générique .
  • A l’exécution, chaque spécialisation différente de la méthode est traitée comme une méthode différente de la même classe.

Tout d’abord, clarifions deux choses. Ceci est une définition de méthode générique:

 T M(T x) { return x; } 

Ceci est une définition de type générique:

 class C { } 

Très probablement, si je vous demande ce qu’est M , vous allez dire que c’est une méthode générique qui prend un T et retourne un T C’est tout à fait correct, mais je propose une façon différente de penser à cela – il y a deux ensembles de parameters ici. L’un est le type T , l’autre est l’object x . Si nous les combinons, nous soaps que cette méthode prend deux parameters au total.


Le concept de currying nous dit qu’une fonction qui prend deux parameters peut être transformée en une fonction qui prend un paramètre et renvoie une autre fonction prenant l’autre paramètre (et vice versa). Par exemple, voici une fonction qui prend deux entiers et produit leur sum:

 Func uncurry = (x, y) => x + y; int sum = uncurry(1, 3); 

Et voici une forme équivalente, où nous avons une fonction qui prend un entier et produit une fonction qui prend un autre entier et retourne la sum des entiers susmentionnés:

 Func> curry = x => y => x + y; int sum = curry(1)(3); 

Nous sums passés d’une fonction qui prend deux entiers à une fonction qui prend un entier et crée des fonctions . Évidemment, ces deux choses ne sont pas littéralement la même chose en C #, mais ce sont deux manières différentes de dire la même chose, car si vous transmettez la même information, vous finirez par atteindre le même résultat final.

Le curry nous permet de raisonner sur les fonctions plus facilement (il est plus facile de raisonner sur un paramètre que sur deux) et cela nous permet de savoir que nos conclusions sont toujours pertinentes pour un nombre quelconque de parameters.


Considérez un instant que, sur un plan abstrait, c’est ce qui se passe ici. Disons que M est une “super-fonction” qui prend un type T et retourne une méthode régulière. Cette méthode renvoyée prend une valeur T et renvoie une valeur T

Par exemple, si on appelle la super-fonction M avec l’argument int , on obtient une méthode régulière de int en int :

 Func e = M; 

Et si nous appelons cette méthode régulière avec l’argument 5 , nous obtenons un retour de 5 , comme prévu:

 int v = e(5); 

Considérons donc l’expression suivante:

 int v = M(5); 

Voyez-vous maintenant pourquoi cela pourrait être considéré comme deux appels distincts? Vous pouvez reconnaître l’appel à la super-fonction car ses arguments sont passés dans <> . Ensuite, l’appel à la méthode renvoyée suit, où les arguments sont passés dans () . C’est analogue à l’exemple précédent:

 curry(1)(3); 

De même, une définition de type générique est également une super-fonction qui prend un type et renvoie un autre type. Par exemple, List est un appel à la super-fonction List avec un argument int qui renvoie un type qui est une liste d’entiers.

Maintenant, lorsque le compilateur C # rencontre une méthode normale, il le comstack comme une méthode normale. Il ne tente pas de créer différentes définitions pour différents arguments possibles. Donc ça:

 int Square(int x) => x * x; 

est compilé tel quel. Il n’est pas compilé comme suit:

 int Square__0() => 0; int Square__1() => 1; int Square__2() => 4; // and so on 

En d’autres termes, le compilateur C # n’évalue pas tous les arguments possibles pour cette méthode afin de les incorporer dans l’exacutable final – il laisse plutôt la méthode dans sa forme paramétrée et fait confiance au résultat qui sera évalué à l’exécution.

De même, lorsque le compilateur C # rencontre une super-fonction (une méthode générique ou une définition de type), il le comstack en tant que super-fonction. Il ne tente pas de créer différentes définitions pour différents arguments possibles. Donc ça:

 T M(T x) => x; 

est compilé tel quel. Il n’est pas compilé comme suit:

 int M(int x) => x; int[] M(int[] x) => x; int[][] M(int[][] x) => x; // and so on float M(float x) => x; float[] M(float[] x) => x; float[][] M(float[][] x) => x; // and so on 

Encore une fois, le compilateur C # approuve le fait que lorsque cette super-fonction sera appelée, elle sera évaluée à l’exécution et que la méthode ou le type habituel sera généré par cette évaluation.

C’est l’une des raisons pour lesquelles C # bénéficie d’un compilateur JIT dans le cadre de son exécution. Lorsqu’une super-fonction est évaluée, elle produit une nouvelle méthode ou un type qui n’existait pas au moment de la compilation! Nous appelons ce processus la réification . Par la suite, le runtime se souvient de ce résultat pour ne pas avoir à le recréer à nouveau. Cette partie est appelée mémo .

Comparez avec C ++ qui ne nécessite pas de compilateur JIT dans le cadre de son exécution. Le compilateur C ++ doit évaluer les super-fonctions (appelées “templates”) au moment de la compilation. C’est une option réalisable car les arguments des super-fonctions sont limités aux éléments pouvant être évalués au moment de la compilation.


Donc, pour répondre à votre question:

 class Foo { public void Bar() { } } 

Foo est un type régulier et il n’y en a qu’un. Bar est une méthode régulière à l’intérieur de Foo et il n’y en a qu’une.

 class Foo { public void Bar() { } } 

Foo est une super-fonction qui crée des types à l’exécution. Chacun de ces types résultants a sa propre méthode nommée Bar et il n’y en a qu’un seul (pour chaque type).

 class Foo { public void Bar() { } } 

Foo est un type régulier et il n’y en a qu’un. Bar est une super-fonction qui crée des méthodes régulières à l’exécution. Chacune de ces méthodes résultantes sera alors considérée comme faisant partie du type régulier Foo .

 class Foo<Τ1> { public void Bar() { } } 

Foo est une super-fonction qui crée des types à l’exécution. Chacun de ces types résultants possède sa propre fonction nommée Bar qui crée des méthodes régulières à l’exécution (ultérieurement). Chacune de ces méthodes résultantes est considérée comme faisant partie du type qui a créé la super-fonction correspondante.


Ce qui précède est l’explication conceptuelle. De plus, certaines optimisations peuvent être mises en œuvre pour réduire le nombre d’implémentations distinctes en mémoire – par exemple, deux méthodes construites peuvent partager une même implémentation de code machine dans certaines circonstances. Voir la réponse de Luaan sur la raison pour laquelle le CLR peut faire cela et quand il le fait réellement.

En IL même, il n’y a qu’une “copie” du code, comme dans C #. Les génériques sont entièrement pris en charge par IL, et le compilateur C # n’a pas besoin de faire de trucs. Vous constaterez que chaque réification d’un type générique (par exemple, List ) a un type distinct, mais ils gardent toujours une référence au type générique ouvert d’origine (par exemple, List<> ); cependant, en même temps, selon le contrat, ils doivent se comporter comme s’il existait des méthodes ou des types distincts pour chaque générique fermé. La solution la plus simple est donc de faire de chaque méthode générique fermée une méthode distincte.

Maintenant, pour les détails de l’implémentation 🙂 En pratique, cela est rarement nécessaire et peut être coûteux. Donc, ce qui se passe réellement, c’est que si une seule méthode peut gérer plusieurs arguments de type, ce sera le cas. Cela signifie que tous les types de référence peuvent utiliser la même méthode (la sécurité du type est déjà déterminée au moment de la compilation, il n’est donc pas nécessaire de la récupérer à nouveau) et avec un peu de ruse avec des champs statiques, vous pouvez utiliser la même méthode. tapez “aussi bien. Par exemple:

 class Foo { private static int Counter; public static int DoCount() => Counter++; public static bool IsOk() => true; } Foo.DoCount(); // 0 Foo.DoCount(); // 1 Foo.DoCount(); // 0 

Il n’y a qu’une seule “méthode” d’ IsOk pour IsOk , et elle peut être utilisée par Foo et Foo (ce qui signifie bien sûr que les appels à cette méthode peuvent être identiques). Mais leurs champs statiques sont toujours séparés, comme requirejs par la spécification de la CLI, ce qui signifie également que DoCount doit faire référence à deux champs distincts pour Foo et Foo . Et pourtant, quand je fais le désassemblage (sur mon ordinateur, attention, ce sont des détails d’implémentation qui peuvent varier un peu, aussi, il faut un peu d’effort pour empêcher l’inclusion de DoCount ), il n’ya qu’une seule méthode DoCount . Comment? La “référence” au Counter est indirecte:

 000007FE940D048E mov rcx, 7FE93FC5C18h ; Foo 000007FE940D0498 call 000007FE940D00C8 ; Foo<>.DoCount() 000007FE940D049D mov rcx, 7FE93FC5C18h ; Foo 000007FE940D04A7 call 000007FE940D00C8 ; Foo<>.DoCount() 000007FE940D04AC mov rcx, 7FE93FC5D28h ; Foo 000007FE940D04B6 call 000007FE940D00C8 ; Foo<>.DoCount() 

Et la méthode DoCount ressemble à ceci (sauf le prolog et “je ne veux pas incorporer cette méthode”):

 000007FE940D0514 mov rcx,rsi ; RCX was stored in RSI in the prolog 000007FE940D0517 call 000007FEF3BC9050 ; Load Foo address 000007FE940D051C mov edx,dword ptr [rax+8] ; EDX = Foo.Counter 000007FE940D051F lea ecx,[rdx+1] ; ECX = RDX + 1 000007FE940D0522 mov dword ptr [rax+8],ecx ; Foo.Counter = ECX 000007FE940D0525 mov eax,edx 000007FE940D0527 add rsp,30h 000007FE940D052B pop rsi 000007FE940D052C ret 

Donc, le code a fondamentalement “injecté” la dépendance Foo / Foo , aussi, bien que les appels soient différents, la méthode appelée est en fait la même – avec un peu plus d’indirection. Bien sûr, pour notre méthode d’origine ( () => Counter++ ), ce ne sera pas un appel du tout, et n’aura pas d’indirection supplémentaire – il sera simplement intégré au site d’appel.

C’est un peu plus compliqué pour les types de valeur. Les champs de types de référence ont toujours la même taille – la taille de la référence. D’un autre côté, les champs de types de valeur peuvent avoir des tailles différentes, par exemple int vs long ou decimal . L’indexation d’un tableau d’entiers nécessite un assemblage différent de l’indexation d’un tableau de decimal . Et comme les structures peuvent aussi être génériques, la taille de la structure peut dépendre de la taille des arguments de type:

 struct Container { public T Value; } default(Container); // Can be as small as 8 bytes default(Container); // Can never be smaller than 16 bytes 

Si nous ajoutons des types de valeur à notre exemple précédent

 Foo.DoCount(); Foo.DoCount(); Foo.DoCount(); 

On obtient ce code:

 000007FE940D04BB call 000007FE940D00F0 ; Foo.DoCount() 000007FE940D04C0 call 000007FE940D0118 ; Foo.DoCount() 000007FE940D04C5 call 000007FE940D00F0 ; Foo.DoCount() 

Comme vous pouvez le voir, même si nous n’obtenons pas l’indirection supplémentaire pour les champs statiques contrairement aux types de référence, chaque méthode est en fait entièrement distincte. Le code dans la méthode est plus court (et plus rapide), mais ne peut pas être réutilisé (ceci est pour Foo.DoCount() :

 000007FE940D058B mov eax,dword ptr [000007FE93FC60D0h] ; Foo.Counter 000007FE940D0594 lea edx,[rax+1] 000007FE940D0597 mov dword ptr [7FE93FC60D0h],edx 

Juste un access aux champs statiques comme si le type n’était pas du tout générique – comme si nous venions de définir la class FooOfInt et la class FooOfDouble .

La plupart du temps, ce n’est pas vraiment important pour vous. Les génériques bien conçus paient généralement plus que leurs coûts, et vous ne pouvez pas vous contenter de vous prononcer sur la performance des génériques. Utiliser une List sera presque toujours une meilleure idée que d’utiliser ArrayList de ints – vous payez le coût supplémentaire de la mémoire en ayant plusieurs méthodes List<> , mais à moins que vous ayez beaucoup de types List<> s sans éléments, les économies surpasseront probablement le coût en mémoire et en temps. Si vous n’avez qu’une réification d’un type générique donné (ou que toutes les révisions sont fermées sur les types de référence), vous ne paierez généralement pas de frais supplémentaires – il peut y avoir un peu d’indirection supplémentaire si l’inclusion n’est pas possible.

Il existe quelques directives pour utiliser efficacement les génériques. Le plus pertinent ici est de ne garder que les pièces génériques en fait. Dès que le type conteneur est générique, tout ce qui est à l’intérieur peut aussi être générique – donc, si vous avez 100 ko de champs statiques dans un type générique, chaque réification devra le dupliquer. C’est peut-être ce que vous voulez, mais cela pourrait être une erreur. L’approche habituelle consiste à placer les parties non génériques dans une classe statique non générique. La même chose s’applique aux classes nestedes – la class Foo { class Bar { } } signifie que Bar est aussi une classe générique (elle “hérite” de l’argument type de sa classe contenant).

Sur mon ordinateur, même si je garde la méthode DoCount exempte de tout élément générique (remplacez Counter++ par seulement 42 ), le code est toujours le même: les compilateurs n’essaient pas d’éliminer la “généricité” inutile. Si vous avez besoin de beaucoup de révisions différentes d’un type générique, cela peut s’accumuler rapidement – pensez donc à séparer ces méthodes; les mettre dans une classe de base non générique ou une méthode d’extension statique peut s’avérer utile. Mais comme toujours avec la performance – profil. Ce n’est probablement pas un problème.