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

J'ai lu quelques informations sur les génériques .ΝΕΤ et remarqué une chose intéressante.

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

class Foo<T> 
{ 
    public static int Counter; 
}

Console.WriteLine(++Foo<int>.Counter); //1
Console.WriteLine(++Foo<string>.Counter); //1

Deux classes Foo<int> et Foo<string> sont différents au moment de l'exécution. Mais qu'en est-il du cas où la classe non générique a une méthode générique?

class Foo 
{
    public void Bar<T>()
    {
    }
}

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 paramètres avec lesquels elles ont utilisé. Cela signifie-t-il que la classe Foo a de nombreuses implémentations de Bar et où les informations sur cette méthode stockées dans la mémoire?

54
demandé sur Theodoros Chatzigiannakis 2017-01-04 13:29:37

3 réponses

Contrairement aux modèles C++ , les génériques. Net sont évalués à l'exécution, pas au moment de la compilation. Sémantiquement, si vous instanciez la classe générique avec des paramètres 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 (intermediate language) compilé.

Types Génériques

La différence entre différentes instanciatons du même type générique devient évidente lorsque vous utilisez Reflection : typeof(YourClass<int>) ne sera pas le même que typeof(YourClass<string>). Ceux-ci sont appelés types génériques construits. Il existe également un {[2] } qui représente la définition de type générique . Voici quelques Autres conseils sur le traitement des génériques via la réflexion.

Lorsque vousinstanciez une classe générique construite , le runtime génère une classe spécialisée à la volée. Il existe des différences subtiles entre la façon dont cela fonctionne avec les types de valeur et de référence.

  • le compilateur ne générera qu'un seul type générique dans l'assemblage.
  • le runtime crée une version distincte de votre classe générique pour chaque type de valeur avec lequel vous l'utilisez.
  • L'exécution alloue un ensemble distinct de champs statiques pour chaque paramètre de type de la classe générique.
  • étant donné que les types de référence ont la même taille, le runtime peut réutiliser la version spécialisée qu'il a générée la première fois que vous l'avez utilisée avec un type de référence.

Méthodes Génériques

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

  • le compilateur ne génère qu'une seule méthode générique, qui est la définition de la méthode générique .
  • en 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.
50
répondu Venemo 2017-01-04 11:08:50

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

T M<T>(T x) 
{
    return x;
}

Il s'agit d'une définition de type générique :

class C<T>
{
}

Très probablement, si je vous demande ce qu'est M, Vous direz que c'est une méthode générique qui prend un T et renvoie un T. C'est tout à fait correct, mais je propose une façon différente de penser à ce sujet - il y a deux ensembles de paramètres ici. L'un est le type T, l'autre est l'objet x. Si nous les combinons, nous savons que, collectivement, cette méthode prend deux paramètres au total.


Le concept de nourrissage nous dit qu'une fonction qui prend deux paramètres peut être transformée en une fonction qui prend un paramètre et retourne une fonction qui prend les autres paramètres (et vice versa). Par exemple, voici une fonction qui prend deux entiers et produit leur somme:

Func<int, int, int> 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 renvoie la somme de ces entiers susmentionnés:

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

Nous sommes passés d'une fonction qui prend deux entiers à une fonction qui prend un entier et crée des fonctions. Évidemment, ces deux ne sont pas littéralement la même chose en C#, mais ce sont deux façons différentes de dire la même chose, car passer la même information finira par vous amener au même résultat final.

Currying nous permet de raison sur les fonctions plus facile (il est plus facile de raisonner sur un paramètre que deux) et cela nous permet de savoir que nos conclusions sont toujours pertinentes pour n'importe quel nombre de paramètres.


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

Par exemple, si nous appelons la super-fonction M, avec l'argument int, nous obtenons une méthode régulière à partir de int à int:

Func<int, int> e = M<int>;

Et si nous appelons cette méthode régulière avec l'argument 5, nous obtenons un 5 retour, comme nous nous y attendions:

int v = e(5);

Considérons donc l'expression suivante:

int v = M<int>(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 retournée suit, où le les arguments sont passés dans (). C'est analogue à l'exemple précédent:

curry(1)(3);

Et 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<int> est un appel à la super-fonction List, avec un argument int qui renvoie un type qui est une liste d'entiers.

Maintenant, quand le compilateur C# rencontre d'une méthode régulière, il compile comme une méthode régulière. Il ne tente pas de créer des définitions différentes pour différents arguments possibles. Donc, ceci:

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

Est compilé tel quel. Il n'est pas compilé comme:

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 intégrer dans l'exacutable final-il laisse plutôt la méthode dans sa forme paramétrée et fait confiance que le résultat sera évalué à l'exécution.

De même, lorsque le compilateur C # répond à une super-fonction (une méthode générique ou une définition de type), il la compile comme une super-fonction. Il n'essaie pas de créer différentes définitions pour différents arguments possibles. Donc, ceci:

T M<T>(T x) => x;

Est compilé tel quel. Il n'est pas compilé comme:

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# A confiance que lorsque cette super-fonction est appelée, elle sera évaluée à l'exécution, et la méthode ou le type régulier sera produit 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. Quand une super-fonction est évaluée, elle produit une toute nouvelle méthode ou un type qui n'était pas là au moment de la compilation! Nous appelons ce processus réification. Par la suite, le runtime se souvient de ce résultat afin qu'il n'ait pas à le recréer à nouveau. Cette partie est appelée memoization.

Comparez avec C++ qui ne nécessite pas de compilateur JIT dans le cadre de son exécution. Le compilateur C++ doit en fait évaluer les super-fonctions (appelées "modèles") au moment de la compilation. C'est un option réalisable car les arguments des super-fonctions sont limités à des choses qui peuvent être évaluées 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 seul. Bar est une méthode régulière à l'intérieur de Foo et il n'y a qu'un.

class Foo<T>
{
    public void Bar()
    {
    }
}

Foo<T> est une super-fonction qui crée des types à l'exécution. Chacun de ces types résultants a sa propre méthode régulière nommée Bar et il n'y a que un de celui-ci (pour chaque type).

class Foo
{
    public void Bar<T>()
    {
    }
}

Foo est un type régulier et il n'y en a qu'un seul. Bar<T> 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<T2>()
    {
    }
}

Foo<T1> est une super-fonction qui crée des types à l'exécution. Chacun de ces types résultants a sa propre super-fonction nommée Bar<T2> qui crée des méthodes régulières lors de l'exécution (ultérieurement). Chacun de ces les méthodes résultantes sont considérées comme faisant partie du type qui a créé la super-fonction correspondante.


Ce qui précède est l'explication conceptuelle. Au-delà, certaines optimisations peuvent être implémentées pour réduire le nombre d'implémentations distinctes en mémoire-par exemple, deux méthodes construites peuvent partager une seule implémentation de code machine dans certaines circonstances. Voir la réponse de Luaan {[59] } sur pourquoi le CLR peut le faire et quand il le fait réellement.

30
répondu Theodoros Chatzigiannakis 2017-05-23 12:09:10

Dans IL lui-même, il n'y a qu'une "copie" du code, tout comme en C#. Les génériques sont entièrement pris en charge par IL, et le compilateur C# n'a pas besoin de faire des astuces. Vous constaterez que chaque réification d'un type générique (par exemple List<int>) a un type distinct, mais ils gardent toujours une référence au type générique ouvert original (par exemple List<>); cependant, en même temps, selon le contrat, ils doivent se comporter comme s'il y avait des méthodes ou des types distincts pour chaque générique fermé. Donc la solution la plus simple est en effet de que chaque méthode générique fermée soit une méthode distincte.

Maintenant pour les détails de mise en œuvre :) 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, elle le fera. 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 l'avoir à nouveau en cours d'exécution), et avec un peu de ruse avec des champs statiques, vous pouvez utiliser la même chose "type" ainsi. Par exemple:

class Foo<T>
{
  private static int Counter;

  public static int DoCount() => Counter++;
  public static bool IsOk() => true;
}

Foo<string>.DoCount(); // 0
Foo<string>.DoCount(); // 1
Foo<object>.DoCount(); // 0

Il N'y a qu'une seule "méthode" d'assemblage pour IsOk, et elle peut être utilisée à la fois par Foo<string> et Foo<object> (ce qui signifie Bien sûr que les appels à cette méthode peuvent être les mêmes). Mais leurs champs statiques sont toujours séparés, comme requis par la spécification CLI, ce qui signifie également que DoCount doit faire référence à deux champs distincts pour Foo<string> et Foo<object>. Et pourtant, quand je fais le démontage (sur mon ordinateur, vous l'esprit - ce sont des détails de mise en œuvre et peut varier un peu; en outre, il faut un peu d'effort pour empêcher l'inlining de DoCount), il n'y a qu'une seule méthode DoCount. Comment? La "référence" à Counter est indirecte:

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

Et la méthode DoCount ressemble à ceci (à l'exclusion du prologue et du remplisseur" Je ne veux pas intégrer cette méthode"):

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

Donc, le code a essentiellement "injecté" le Foo<string>/Foo<object> dépendance, donc alors que les appels sont différents, la méthode appelée est en fait la même - juste avec un peu plus d'indirection. Bien sûr, pour notre méthode originale (() => Counter++), Ce ne sera pas un appel du tout, et n'aura pas l'indirection supplémentaire-il sera juste en ligne dans le callsite.

C'est un peu plus délicat pour les types de valeur. Les types de champs de référence ont toujours la même taille-la taille de la référence. D'autre part, les champs de types de valeurs peuvent avoir des tailles différentes, par exemple int vs. long ou decimal. L'indexation d'un tableau d'entiers nécessite différentes assemblée que l'indexation d'un tableau de decimals. Et depuis structs peut être trop générique, la taille de la structure peut dépendre de la taille des arguments de type:

struct Container<T>
{
  public T Value;
}

default(Container<double>); // Can be as small as 8 bytes
default(Container<decimal>); // Can never be smaller than 16 bytes

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

Foo<int>.DoCount();
Foo<double>.DoCount();
Foo<int>.DoCount();

Nous obtenons ce code:

000007FE940D04BB  call        000007FE940D00F0  ; Foo<int>.DoCount()
000007FE940D04C0  call        000007FE940D0118  ; Foo<double>.DoCount()
000007FE940D04C5  call        000007FE940D00F0  ; Foo<int>.DoCount()

Comme vous pouvez le voir, bien que nous n'obtenions pas l'indirection supplémentaire pour les champs statiques contrairement aux types de référence, chaque méthode est en fait entièrement séparée. Le code de la méthode est plus court (et plus rapide), mais ne peut pas être réutilisé (ceci est pour Foo<int>.DoCount():

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

Juste une simple statique l'accès au champ comme si le type n'était pas générique du tout-comme si nous venions de définir class FooOfInt et 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 simplement faire une déclaration plate sur la performance des génériques. Utiliser un List<int> sera presque toujours une meilleure idée que d'utiliser ArrayList d'ints-vous payez le coût de mémoire supplémentaire d'avoir plusieurs méthodes List<>, mais à moins que vous n'ayez beaucoup de type de valeur différent List<>avec aucun élément, les économies seront probablement bien l'emporter sur le coût à la fois la mémoire et le temps. Si vous n'avez qu'une seule réification d'un type générique donné (ou que toutes les réifications sont fermées sur les types de référence), vous ne payez généralement pas de supplément - il peut y avoir un peu d'indirection supplémentaire si l'inlining n'est pas possible.

Il y a quelques directives pour utiliser efficacement les génériques. Le plus pertinent ici est de ne garder que les pièces réellement génériques génériques. Dès que le type contenant est générique, tout à l'intérieur peut également être générique - donc si vous avez 100 Ko de champs statiques dans un type générique, chaque réification devra dupliquer cela. C'est peut être ce que vous voulez, mais il 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 imbriquées - class Foo<T> { 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 le DoCount méthode libre de tout Générique (remplacez Counter++ par juste 42), le code est toujours le même - les compilateurs n'essaient pas d'éliminer la "généricité"inutile. Si vous avez besoin d'utiliser beaucoup de réifications différentes d'un type générique, cela peut s'additionner rapidement - alors pensez à garder ces méthodes à part; les mettre dans une classe de base non générique ou une méthode d'extension statique pourrait être utile. Mais comme toujours avec performance-profil. Il n'est probablement pas un problème.

15
répondu Luaan 2017-01-04 12:53:32