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_Node
et à 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 T
remplacés par les types qui sont utilisé. Par exemple add<int>(5, 6)
, stockera une copie de la fonction générique add
et 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_Node
où la liste des copies copies.size() > 0
, vous invoquez visitFunction
sur 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.
Réponses:
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.
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:
int
est un type, un paramètre de typeT
est un type, pour tout typeX
, le type de tableauX[]
est également un type, etc.Les règles pour les génériques impliquent la substitution. Par exemple,
class C with one type parameter
n'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
puis le compilateur vérifie que dans
C<int>
, l'argumentint
est une substitution valide pourT
, 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 aC<int>
est créé pour la première fois et génère dynamiquement le code machine approprié.la source
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:
peut être compilé, vérifié par type et appelé de la même manière que
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
: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 x
n'aurait pas à encadrer l'x
argument. 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:
Appliquez le modèle aux arguments de modèle fournis. Par exemple, appeler
template<class T> T add(T a, T b) { … }
commeadd<int>(1, 2)
nous donnerait la fonction réelleint __add__T_int(int a, int b)
(ou quelle que soit l'approche de manipulation de nom utilisée).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é.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.
la source