Comment les génériques sont-ils implémentés dans un compilateur moderne?

15

Ce que je veux dire ici, c'est comment passer d'un modèle T add(T a, T b) ...au code généré? J'ai pensé à quelques façons d'y parvenir, nous stockons la fonction générique dans un AST au fur Function_Nodeet à chaque fois que nous l'utilisons, nous stockons dans le nœud de fonction d'origine une copie d'elle-même avec tous les types Tremplacés par les types qui sont utilisé. Par exemple add<int>(5, 6), stockera une copie de la fonction générique addet remplacera tous les types T de la copie par int.

Cela ressemblerait donc à quelque chose comme:

struct Function_Node {
    std::string name; // etc.
    Type return_type;
    std::vector<std::pair<Type, std::string>> arguments;
    std::vector<Function_Node> copies;
};

Ensuite, vous pouvez générer du code pour ceux-ci et lorsque vous visitez un Function_Nodeoù la liste des copies copies.size() > 0, vous invoquez visitFunctionsur toutes les copies.

visitFunction(Function_Node& node) {
    if (node.copies.size() > 0) {
        for (auto& node : nodes.copies) {
            visitFunction(node);
        }
        // it's a generic function so we don't want
        // to emit code for this.
        return;
    }
}

Est-ce que cela fonctionnerait bien? Comment les compilateurs modernes abordent-ils ce problème? Je pense que peut-être une autre façon de le faire serait d'injecter les copies dans l'AST afin qu'il traverse toutes les phases sémantiques. J'ai aussi pensé que vous pourriez peut-être les générer sous une forme immédiate comme Rust's MIR ou Swifts SIL par exemple.

Mon code est écrit en Java, les exemples ici sont en C ++ parce que c'est un peu moins détaillé pour les exemples - mais le principe est fondamentalement la même chose. Bien qu'il puisse y avoir quelques erreurs car elles sont juste écrites à la main dans la boîte à questions.

Notez que je veux dire un compilateur moderne comme dans quelle est la meilleure façon d'aborder ce problème. Et quand je dis les génériques, je ne veux pas dire comme les génériques Java où ils utilisent l'effacement de type.

Jon Flow
la source
En C ++ (d'autres langages de programmation ont des génériques, mais ils l'implémentent chacun différemment), c'est fondamentalement un système de macro géant à la compilation. Le code réel est généré à l'aide du type substitué.
Robert Harvey
Pourquoi ne pas taper l'effacement? Gardez à l'esprit que ce n'est pas seulement Java qui le fait, et ce n'est pas une mauvaise technique (selon vos besoins).
Andres F.
@AndresF. Je pense qu'étant donné le fonctionnement de ma langue, cela ne marcherait pas bien.
Jon Flow du
2
Je pense que vous devriez préciser de quel type de génériques parlez-vous. Par exemple, les modèles C ++, les génériques C # et les génériques Java sont tous très différents les uns des autres. Vous dites que vous ne voulez pas dire les génériques Java, mais vous ne dites pas ce que vous voulez dire.
svick
2
Cela doit vraiment se concentrer sur le système d'une langue pour éviter d'être trop large
Daenyth

Réponses:

14

Comment les génériques sont-ils implémentés dans un compilateur moderne?

Je vous invite à lire le code source d'un compilateur moderne si vous souhaitez savoir comment fonctionne un compilateur moderne. Je commencerais par le projet Roslyn, qui implémente des compilateurs C # et Visual Basic.

En particulier, j'attire votre attention sur le code du compilateur C # qui implémente les symboles de type:

https://github.com/dotnet/roslyn/tree/master/src/Compilers/CSharp/Portable/Symbols

et vous pourriez également vouloir regarder le code pour les règles de convertibilité. Il y a beaucoup de choses qui se rapportent à la manipulation algébrique des types génériques.

https://github.com/dotnet/roslyn/tree/master/src/Compilers/CSharp/Portable/Binder/Semantics/Conversions

J'ai fait de gros efforts pour rendre ce dernier facile à lire.

J'ai pensé à quelques façons d'y parvenir, nous stockons la fonction générique dans un AST en tant que Function_Node, puis chaque fois que nous l'utilisons, nous stockons dans le nœud de fonction d'origine une copie de lui-même avec tous les types T substitués par les types qui sont utilisés.

Vous décrivez des modèles , pas des génériques . C # et Visual Basic ont des génériques réels dans leurs systèmes de types.

En bref, ils fonctionnent comme ça.

  • Nous commençons par établir des règles pour ce qui constitue formellement un type au moment de la compilation. Par exemple: intest un type, un paramètre de type Test un type, pour tout type X, le type de tableau X[]est également un type, etc.

  • Les règles pour les génériques impliquent la substitution. Par exemple, class C with one type parametern'est pas un type. C'est un modèle pour faire des types. class C with one type parameter called T, under substitution with int for T est un type.

  • Les règles décrivant les relations entre les types - compatibilité lors de l'affectation, comment déterminer le type d'une expression, etc. - sont conçues et implémentées dans le compilateur.

  • Un langage de bytecode qui prend en charge les types génériques dans son système de métadonnées est conçu et implémenté.

  • Au moment de l'exécution, le compilateur JIT transforme le bytecode en code machine; il est responsable de la construction du code machine approprié compte tenu d'une spécialisation générique.

Ainsi, par exemple, en C # lorsque vous dites

class C<T> { public void X(T t) { Console.WriteLine(t); } }
...
var c = new C<int>(); 
c.X(123);

puis le compilateur vérifie que dans C<int>, l'argument intest une substitution valide pour T, et génère des métadonnées et du bytecode en conséquence. Au moment de l'exécution, la gigue détecte que a C<int>est créé pour la première fois et génère dynamiquement le code machine approprié.

Eric Lippert
la source
9

La plupart des implémentations de génériques (ou plutôt: polymorphisme paramétrique) utilisent l'effacement de type. Cela simplifie considérablement le problème de la compilation de code générique, mais ne fonctionne que pour les types encadrés: puisque chaque argument est effectivement un pointeur opaque, nous avons besoin d'un VTable ou d'un mécanisme de répartition similaire pour effectuer des opérations sur les arguments. En Java:

<T extends Addable> T add(T a, T b) { … }

peut être compilé, vérifié par type et appelé de la même manière que

Addable add(Addable a, Addable b) { … }

sauf que les génériques fournissent au vérificateur de type beaucoup plus d'informations sur le site d'appel. Ces informations supplémentaires peuvent être traitées avec des variables de type , en particulier lorsque des types génériques sont déduits. Lors de la vérification de type, chaque type générique peut être remplacé par une variable, appelons-le $T1:

$T1 add($T1 a, $T1 b)

La variable de type est ensuite mise à jour avec plus de faits au fur et à mesure qu'ils deviennent connus, jusqu'à ce qu'elle puisse être remplacée par un type concret. L'algorithme de vérification de type doit être écrit d'une manière qui accepte ces variables de type même si elles ne sont pas encore résolues en un type complet. En Java lui-même, cela peut généralement être fait facilement car le type des arguments est souvent connu avant que le type de l'appel de fonction doive être connu. Une exception notable est une expression lambda comme argument de fonction, qui nécessite l'utilisation de telles variables de type.

Bien plus tard, un optimiseur peut générer du code spécialisé pour un certain ensemble d'arguments, ce serait alors effectivement une sorte d'inline.

Un VTable pour les arguments de type générique peut être évité si la fonction générique n'effectue aucune opération sur le type, mais les transmet uniquement à une autre fonction. Par exemple, la fonction Haskell call :: (a -> b) -> a -> b; call f x = f xn'aurait pas à encadrer l' xargument. Cependant, cela nécessite une convention d'appel qui peut passer par des valeurs sans connaître leur taille, ce qui la limite essentiellement aux pointeurs de toute façon.


Le C ++ est très différent de la plupart des langages à cet égard. Une classe ou une fonction basée sur un modèle (je ne parlerai ici que des fonctions basées sur un modèle) n'est pas appelable en soi. Au lieu de cela, les modèles doivent être compris comme une méta-fonction au moment de la compilation qui renvoie une fonction réelle. Ignorant l'inférence d'argument de modèle pendant un moment, l'approche générale se résume alors à ces étapes:

  1. Appliquez le modèle aux arguments de modèle fournis. Par exemple, appeler template<class T> T add(T a, T b) { … }comme add<int>(1, 2)nous donnerait la fonction réelle int __add__T_int(int a, int b)(ou quelle que soit l'approche de manipulation de nom utilisée).

  2. Si le code de cette fonction a déjà été généré dans l'unité de compilation actuelle, continuez. Sinon, générez le code comme si une fonction int __add__T_int(int a, int b) { … }avait été écrite dans le code source. Cela implique de remplacer toutes les occurrences de l'argument de modèle par ses valeurs. Il s'agit probablement d'une transformation AST → AST. Ensuite, effectuez une vérification de type sur l'AST généré.

  3. Compilez l'appel comme si le code source l'avait été __add__T_int(1, 2).

Notez que les modèles C ++ ont une interaction complexe avec le mécanisme de résolution de surcharge, que je ne veux pas décrire ici. Notez également que cette génération de code rend impossible d'avoir une méthode basée sur des modèles qui est également virtuelle - une approche basée sur l'effacement de type ne souffre pas de cette restriction substantielle.


Qu'est-ce que cela signifie pour votre compilateur et / ou votre langue? Vous devez réfléchir soigneusement au type de génériques que vous souhaitez offrir. L'effacement de type en l'absence d'inférence de type est l'approche la plus simple possible si vous prenez en charge les types encadrés. La spécialisation des modèles semble assez simple, mais implique généralement un changement de nom et (pour plusieurs unités de compilation) une duplication substantielle dans la sortie, car les modèles sont instanciés sur le site d'appel, pas sur le site de définition.

L'approche que vous avez montrée est essentiellement une approche de modèle de type C ++. Cependant, vous stockez les modèles spécialisés / instanciés en tant que «versions» du modèle principal. C'est trompeur: ils ne sont pas les mêmes conceptuellement, et différentes instanciations d'une fonction peuvent avoir des types très différents. Cela compliquera les choses à long terme si vous autorisez également la surcharge de fonctions. Au lieu de cela, vous auriez besoin d'une notion d'un ensemble de surcharge qui contient toutes les fonctions et modèles possibles qui partagent un nom. À l'exception de la résolution de la surcharge, vous pouvez considérer que différents modèles instanciés sont complètement séparés les uns des autres.

amon
la source