55 votes

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érentes au moment de l'exécution. Mais qu'en cas de non-classe générique génériques méthode?

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

Il est évident qu'il y a seulement un Foo classe. Mais qu'méthode Bar? Toutes les classes génériques et les méthodes sont fermés au moment de l'exécution avec les paramètres qu'il est utilisé avec. Ça veut dire 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?

51voto

Venemo Points 7213

Contrairement aux modèles C++ , .NET génériques sont évalués au cours de l'exécution et non pas au moment de la compilation. Sémantiquement, si vous instancier la classe générique avec différents paramètres de type, ceux-ci vont se comporter comme si elle avait deux classes différentes, mais sous le capot, il y a une seule classe dans l'compilé IL (intermediate language) du code.

Types génériques

La différence entre les différentes instantiatons du même type générique devient évident lorsque vous utilisez Réflexion: typeof(YourClass<int>) ne sera pas le même que typeof(YourClass<string>). Ceux-ci sont appelés construit des types génériques. Il existe aussi un typeof(YourClass<>) qui représente le générique de la définition de type. Voici quelques conseils supplémentaires sur la façon de traiter avec les médicaments génériques par la Réflexion.

Lorsque vous instanciez un construit classe générique, le moteur d'exécution génère une classe spécialisée à la volée. Il y a de subtiles différences entre la façon dont il fonctionne avec de la valeur et les types référence.

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

Méthodes génériques

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

  • Le compilateur génère uniquement une méthode générique, qui est le générique de la définition de la méthode.
  • Dans l'exécution, chaque spécialisation de la méthode est considérée comme une autre méthode de la même classe.

30voto

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

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

C'est un type générique de définition:

class C<T>
{
}

Très probablement, si je vous demande ce que M est, vous allez dire que c'est une méthode générique qui prend un T et renvoie un T. C'est tout à fait correcte, mais je propose une autre façon 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 l'on combine entre eux, 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, d'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:

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

Nous sommes allés d'avoir une fonction qui prend deux entiers en avoir 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 ils sont deux façons différentes de dire la même chose, car en passant la même information finira par atteindre le même résultat final.

Nourrissage nous permet de raisonner sur des fonctions plus facile (il est plus facile de raisonner sur un seul paramètre que deux) et il nous permet de savoir que nos conclusions sont toujours d'actualité pour un nombre quelconque de paramètres.


Imaginez un instant que, d'une façon abstraite, c'est ce qui a lieu ici. Disons - M est un "super-fonction" qui prend un type T et renvoie une méthode régulière. Qui a renvoyé méthode prend un T de la valeur et renvoie un T de la valeur.

Par exemple, si nous appelons le super-fonction M avec l'argument int, nous obtenons une méthode régulière de int de 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 de retour, comme nous nous y attendions:

int v = e(5);

Donc, considère 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 en <>. Ensuite, l'appel pour le retour de la méthode qui suit, où les arguments sont passés dans l' (). C'est analogue à l'exemple précédent:

curry(1)(3);

Et de même, un générique de la définition de type est aussi un super-fonction qui prend un type et retourne 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 cherche pas à créer des définitions différentes pour les différents arguments possibles. Donc, ceci:

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

obtient compilé comme il est. Il n'est pas compilé en tant que:

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, pour les intégrer dans la version finale du exacutable -- ou plutôt, il laisse la méthode dans sa forme paramétrée et espère que le résultat sera évalué au moment de l'exécution.

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

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

obtient compilé comme il est. Il n'est pas compilé en tant que:

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# fiducies que lors de ce super-fonction est appelée, elle sera évaluée au moment de l'exécution, et de la méthode ou le type sera produite par cette évaluation.

C'est une des raisons pour lesquelles le C# est bénéficié d'un JIT-compilateur dans le cadre de son exécution. Quand un super-fonction est évaluée, il produit une image de marque nouvelle méthode ou d'un type qui n'était pas là au moment de la compilation! Nous appelons ce processus de réification. Par la suite, l'exécution se souvient que suite afin de ne pas avoir à re-créer un nouveau. Cette partie est appelée memoization.

Comparer avec le C++ qui ne nécessite pas de JIT-compilateur dans le cadre de son exécution. Le compilateur C++ doit réellement évaluer le super-fonctions (appelées les "templates") au moment de la compilation. C'est une option réalisable car les arguments de la super-fonctions sont limitées à des choses qui peuvent être évalués au moment de la compilation.


Donc, pour répondre à votre question:

class Foo 
{
    public void Bar()
    {
    }
}

Foo est un type ordinaire et il n'y a qu'un. Bar est une méthode régulière à l'intérieur d' Foo et il n'y a qu'un.

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

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

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

Foo est un type ordinaire et il n'y a qu'un. Bar<T> est un super-fonction qui crée méthodes ordinaires au moment de l'exécution. Chacune de ces méthodes sera alors considéré comme faisant partie de l'ordinaire de type Foo.

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

Foo<T1> est un super-fonction qui crée les types à l'exécution. Chacun de ces types possède son propre super-fonction nommée Bar<T2> qui crée des méthodes ordinaires au moment de l'exécution (au plus tard). Chacune de ces méthodes est considéré comme le type qui a créé le super-fonction.


Le ci-dessus est le conceptuel explication. Au-delà, certaines optimisations peuvent être mises en œuvre pour réduire le nombre de différentes implémentations dans la mémoire (par exemple, construit deux méthodes peuvent partager une seule machine-mise en place du code dans certaines circonstances. Voir Luaan de réponse sur le pourquoi de la CLR peut le faire et quand il fait réellement.

15voto

Luaan Points 8934

IL lui-même, il y a juste une "copie" du code, tout comme en C#. Les génériques sont entièrement pris en charge par l'état d'ILLINOIS, et le compilateur C# n'a pas besoin de faire des trucs. 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 à l'ouverture d'origine de type générique (par exemple, List<>); cependant, dans le même temps, par contrat, ils doivent se comporter comme s'il y avait des méthodes ou des types pour chaque fermée générique. Donc la solution la plus simple est en effet de demander à chaque fermée générique de la méthode une méthode distincte.

Maintenant pour les détails de mise en œuvre :) Dans la pratique, c'est rarement nécessaire, et peut être coûteux. Donc ce qui se passe réellement est que si une seule méthode peut gérer plusieurs type d'arguments, il le sera. Cela signifie que tous les types de référence peut utiliser la même méthode (le type de sécurité est déjà déterminé à la compilation, donc il n'y a pas besoin de l'avoir à nouveau dans l'exécution), et avec un peu de ruse avec les champs statiques, vous pouvez utiliser le même "type". 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 assemblée de "méthode" pour IsOk, et il peut être utilisé par les deux Foo<string> et Foo<object> (ce qui bien sûr signifie également que les appels à cette méthode peut être la même). Mais leurs champs statiques sont toujours séparés, tel que requis par la spécification CLI, ce qui signifie également qu' DoCount doit se référer à deux champs distincts pour Foo<string> et Foo<object>. Et pourtant, quand je fais du démontage (sur mon ordinateur, ce sont des détails de mise en œuvre et peut varier un peu; de plus, il prend un peu d'effort pour empêcher l'inlining de DoCount), il n'y a qu'un DoCount méthode. 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 l' DoCount méthode ressemble à quelque chose comme ceci (à l'exclusion du prologue et "je ne veux pas inline cette méthode de" remplissage):

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  

Ainsi, le code de coeur "injecté" l' Foo<string>/Foo<object> de dépendance, alors que les appels sont différentes, la méthode appelée est en fait la même chose avec un peu plus d'indirection. Bien sûr, pour notre méthode originale (() => Counter++), ce ne sera pas un appel à tous, et n'aura pas le supplémentaire d'indirection - il va juste en ligne dans le callsite.

C'est un peu plus compliqué pour les types de valeur. Les champs de types de référence sont toujours de la même taille - la taille de la référence. D'autre part, des 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 différentes assemblée que l'indexation d'un tableau d' decimals. Et puisque les structures peuvent être génériques trop, 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 l'on ajoute les types de valeur à notre exemple précédent

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

Nous obtenir 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, alors que nous n'avons pas l'indirection supplémentaire pour les champs statiques contrairement à avec la des types référence, chaque méthode est en fait entièrement séparé. Le code de la méthode qui est la plus courte (et plus rapide), mais ne peut pas être réutilisé (c'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 un simple champ statique de l'accès que si le type n'était pas générique à tous - comme si nous n'définis class FooOfInt et class FooOfDouble.

La plupart du temps, ce n'est pas vraiment important pour vous. Bien conçu génériques généralement plus que de payer pour leurs frais, et vous ne pouvez pas simplement faire un plat déclaration au sujet de la performance des génériques. À l'aide d'un List<int> sera presque toujours une meilleure idée que d'utiliser ArrayList d'entiers - vous payer le coût mémoire d'avoir plusieurs List<> méthodes, mais à moins d'avoir beaucoup de différents type de valeur List<>s avec n éléments, les économies seront probablement bien l'emportent sur le coût de la mémoire et de temps. Si vous avez seulement une réification d'une donnée de type générique (ou de tous les reifications sont fermés sur les types de référence), généralement, vous n'allez pas payer un supplément peut - être d'un peu plus d'indirection si inline n'est pas possible.

Il y a quelques lignes directrices pour l'utilisation de génériques de manière efficace. La plus pertinente ici est de ne garder que les parties génériques générique. Dès que le contenant est de type générique, tout à l'intérieur peut également être générique, donc si vous avez 100 kio des champs statiques dans un type générique, chaque réification aurez besoin de le dupliquer. C'est peut être ce que vous voulez, mais il pourrait être une erreur. L'habituelle approche est de mettre les non-parties génériques dans un non-générique statique de la classe. La même chose s'applique aux classes imbriquées - class Foo<T> { class Bar { } } signifie qu' Bar est également une classe générique (il "hérite" du type de l'argument de son contenant de la classe).

Sur mon ordinateur, même si je garde l' DoCount méthode libre de quoi que ce soit générique (remplacer Counter++ avec seulement 42), le code est toujours le même - les compilateurs ne pas essayer d'éliminer l'inutile "genericity". Si vous avez besoin d'utiliser beaucoup de différents reifications d'un type générique, ce qui peut ajouter jusqu'rapidement - donc envisager de garder ces méthodes en dehors; les mettre dans un non-générique de la classe de base ou statique de la méthode d'extension pourrait être utile. Mais comme toujours avec la performance de profil. Il n'est probablement pas un problème.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X