Le compilateur de Go utilise une technique appelée GCshape stenciling lors de la compilation des génériques introduits dans la version 1.18. Historiquement, les langages implémentaient des génériques soit par une monomorphisation complète—générant un code machine séparé pour chaque instanciation de type, entraînant un gonflement binaire—soit par boxing—effaçant les types au prix d'une surcharge d'exécution et d'allocation. Le problème auquel Go était confronté était de soutenir une programmation système haute performance où la taille binaire compte, sans sacrifier entièrement la vitesse d'exécution.
La solution consiste à regrouper les types concrets par leur forme GC, définie par leur taille et leur bitmap de pointeur (le motif de pointeurs à l'intérieur du type). Le compilateur génère une seule instanciation de fonction pour tous les types partageant la même forme GC, en passant un dictionnaire d'exécution contenant des métadonnées de type comme paramètre implicite.
// Tant *int que *string partagent la même instanciation // parce qu'ils ont une forme GC identique (un seul pointeur). func Identity[T any](x T) T { return x } func main() { Identity((*int)(nil)) // Utilise l'instanciation #1 Identity((*string)(nil)) // Utilise l'instanciation #1 (même forme) Identity(42) // Utilise l'instanciation #2 (scalaire, pas de pointeurs) }
Notre équipe construisait un pipeline de traitement d'événements à haut débit en utilisant des gestionnaires middleware génériques Handler[T Event]. Nous devions traiter cinquante types d'événements distincts tout en maintenant une faible latence et une taille binaire raisonnable pour un déploiement conteneurisé.
La première approche utilisait interface{} avec des assertions de type, s'appuyant sur des commutateurs de type à l'exécution. Cela offrait de la flexibilité et fonctionnait dans les anciennes versions de Go, mais introduisait une surcharge d'allocation significative—chaque événement encapsulé dans une interface nécessitait une allocation sur le tas—et éliminait la sécurité de type à la compilation, entraînant des panics en production lorsque les types étaient incompatibles.
La deuxième approche impliquait une génération de code à la compilation utilisant go generate avec des outils tiers pour créer HandlerClickEvent, HandlerPurchaseEvent, etc. Cela offrait des performances optimales sans surcharge à l'exécution, mais faisait gonfler notre taille binaire de 40 Mo en prenant en charge cinquante types d'événements, et créait des cauchemars de maintenance lors de la mise à jour des modèles de générateur.
Nous avons choisi la troisième approche : les génériques natifs de Go avec une attention particulière aux formes GC. Nous avons veillé à ce que nos types d'événements soient des pointeurs vers des structures (forme GC uniforme), permettant au compilateur de réutiliser les instanciations. Nous avons accepté la légère surcharge des recherches dans le dictionnaire pour les dispatches de méthodes en échange d'une augmentation de taille binaire de seulement 2 Mo. Le résultat était une réduction de latence de 15 % par rapport à interface{} et une empreinte binaire gérable par rapport à la génération de code complète.
Comment le dictionnaire d'exécution fournit-il des informations spécifiques au type aux instanciations génériques partagées ?
Le dictionnaire est une structure contenant des pointeurs vers des descripteurs de type (_type), des tables de méthodes (itab), et des métadonnées GC. Lorsque le compilateur génère du code pour une fonction générique comme func Print[T any](x T), il passe le dictionnaire comme premier argument implicite. Pour appeler une méthode x.String(), le code généré recherche le pointeur de méthode dans le dictionnaire au lieu de compiler un appel direct, permettant au même code machine de gérer T=bytes.Buffer et T=strings.Builder malgré des implémentations de méthode différentes.
Pourquoi deux types de pointeurs distincts pourraient-ils partager une instanciation générique tandis que leurs types d'éléments nécessitent des instanciations séparées ?
Go classe les types par GCshape, qui se préoccupe uniquement de la disposition mémoire pertinente pour le ramasse-miettes et l'allocateur. Tant *int que *string consistent en un seul mot machine contenant un pointeur, les plaçant dans la même classe de forme. En revanche, int ne contient aucun pointeur et s'aligne sur une taille spécifique, tandis que string est une structure à deux mots contenant un pointeur et une longueur. Parce que leurs dispositions mémoire diffèrent, ils nécessitent des chemins de code générés séparés pour gérer une collecte des ordures et un adressage mémoire corrects.
Quelle est l'implication de performance de l'utilisation des récepteurs de valeur par rapport aux récepteurs de pointeur dans les contraintes génériques ?
Lorsqu'une fonction générique appelle une méthode sur un paramètre de type T, le compilateur doit générer un code qui fonctionne pour n'importe quel T possible. Si la contrainte exige un récepteur de valeur func (T) Method(), mais que le type concret est volumineux, le compilateur peut être contraint de passer des dictionnaires et d'effectuer des appels indirects qui empêchent l'inlining. L'utilisation de récepteurs de pointeur func (*T) Method() permet souvent une meilleure optimisation car les types de pointeur partagent plus souvent les formes GC, et le compilateur peut plus facilement dévirtualiser les appels lorsque le type concret est connu à la compilation dans des contextes d'instanciation spécifiques.