Histoire : Avant Go 1.18, le langage manquait de polymorphisme paramétrique, obligeant les développeurs à choisir entre interface{} (provoquant des allocations sur le tas et des surcharges de packaging) ou génération de code (provoquant une augmentation de la taille binaire). Lors de la conception des génériques, l'équipe de Go a explicitement rejeté le modèle de templates C++ de monomorphisation complète—où chaque instanciation de type distinct produit du code machine dupliqué—en raison de préoccupations concernant l'explosion de la taille binaire dans les grandes applications cloud-native liant des milliers de packages.
Problème : La monomorphisation pure générerait des blocs d'assemblage distincts pour Process[int] et Process[uint] bien que les deux soient des entiers 64 bits, gaspillant le cache d'instructions et l'espace disque. À l'inverse, implémenter des génériques via le packaging (comme en Java) forcerait les types de valeurs sur le tas, détruisant les caractéristiques de performance sans allocation essentielles pour le créneau de programmation système de Go. Le défi était de préserver la sécurité des types à la compilation et les sémantiques de valeur sans coût tout en évitant le problème de duplication de code N fois.
Solution : Go emploie le stencil de forme de GC combiné avec des dictionnaires d'exécution. Le compilateur regroupe les types par forme de GC—définie par la taille, l'alignement et la bitmap de pointeur—plutôt que par l'identité de type exacte. Les types ayant des dispositions mémoires identiques (par exemple, []int et []string, étant tous deux des structures d'en-tête avec un pointeur, len et cap) partagent le même stencil de code machine instancié. Pour les opérations spécifiques aux types comme la dispatch de méthodes ou les assertions de types, le compilateur passe un dictionnaire d'exécution caché contenant des décalages de métadonnées. Cela garantit que Point{X:1, Y:2} et Vector{X:1, Y:2} partagent du code, tout en gardant les types de valeur non emballés sur la pile.
Nous développions un moteur de stockage colonne haute performance nécessitant une implémentation générique de SkipList pour indexer à la fois des horodatages int64 et des structures Decimal128 personnalisées (16 octets, deux champs uint64). Les premières mesures avec interface{} ont montré que 35 % du temps CPU étaient consommés par des allocations de tas à l'exécution et des indirections d'interface, inacceptables pour nos exigences de latence sub-microseconde.
Nous avons considéré trois approches architecturales. Premièrement, la monomorphisation complète via go generate et text/template pour produire des implémentations dédiées SkipListInt64 et SkipListDecimal. Cela a éliminé les allocations mais augmenté notre taille binaire de 22 Mo en prenant en charge douze types numériques distincts, violant nos contraintes de déploiement sans serveur. Deuxièmement, une implémentation unifiée utilisant unsafe.Pointer et la réflexion pour gérer manuellement la mémoire. Cela a maintenu la taille binaire minimale mais a introduit une complexité catastrophique, nécessitant une arithmétique de pointeur manuelle qui a brisé les invariants du ramasse-miettes de Go pendant les tests.
Nous avons sélectionné la troisième approche : les génériques natifs de Go avec une attention particulière au regroupement de forme de GC. Nous avons aligné notre structure Decimal128 pour correspondre à la disposition mémoire de [2]uint64, garantissant qu'elle partage le code stencil avec d'autres types de valeur de 16 octets. En analysant la sortie du compilateur avec go tool objdump, nous avons vérifié que SkipList[int64] et SkipList[uint64] partageaient des blocs d'assemblage identiques, tandis que SkipList[string] utilisait correctement un stencil séparé en raison de sa bitmap contenant un pointeur. Cette approche hybride a réduit la taille binaire de 58 % par rapport à la génération de code tout en maintenant un débit sans allocation. Le résultat était une amélioration de latence de 4x par rapport à la version interface{} et une taille binaire inférieure à 30 Mo.
Pourquoi deux types de structure distincts avec des types de champ identiques génèrent parfois des instanciations génériques séparées, tandis qu'une structure et un alias de type d'un primitif peuvent partager du code ?
Cela se produit car le regroupement de forme de GC dépend du descripteur de type d'exécution complet, y compris les bitmaps de pointeur et les remplissages, pas seulement des types de champ superficiels. Si type A struct { x, y int } et type B struct { x, y int } sont définis dans des packages différents, ils partagent la même forme de GC et le stencil. Cependant, *type C struct { x int; y int } a une bitmap de pointeur différente de type D struct { x, y int }, forçant une génération de code machine séparée. À l'inverse, type MyInt int et int partagent des formes, mais struct { _ int; x int } et struct { x int } peuvent différer en raison du remplissage d'alignement. Comprendre que le ramasse-miettes nécessite des cartes de pile précises pour chaque variable vivante explique pourquoi l'identité de disposition l'emporte sur l'identité de type nominale.
Comment la dispatch de méthode sur des paramètres de type génériques diffère-t-elle des appels directs concrets, et pourquoi cette surcharge est-elle inévitable sans monomorphisation complète ?
Lors de l'appel d'une méthode sur un paramètre de type générique T, le compilateur émet un appel indirect via le dictionnaire d'exécution plutôt qu'une adresse de fonction directe. Contrairement aux appels d'interface—qui résolvent les méthodes via l'itab à l'exécution—les entrées du dictionnaire générique sont résolues à la compilation mais passées comme paramètres cachés. Cela introduit un niveau d'indirection (généralement de 2 à 5 nanosecondes) par rapport au code monomorphisé sans coût. Les candidats supposent souvent que les génériques n'ont pas de frais généraux par rapport au code spécialisé à la main ; en réalité, la recherche dans le dictionnaire empêche certaines optimisations d'inlining que la monomorphisation permettrait, bien que cela reste de plusieurs ordres de grandeur plus rapide que reflect.Value.Call.
Pourquoi l'instanciation d'un type générique avec un champ d'identifiant vide (par exemple, struct { _ int64; x int64 }) peut-elle potentiellement forcer le compilateur à générer un stencil unique, augmentant la taille binaire ?
Les champs vides occupent de l'espace et contribuent à la bitmap de pointeur de la structure même s'ils ne sont pas nommés, modifiant potentiellement la forme de GC. Une struct { _ int64; x int64 } a une taille et un alignement différents de struct { x int64 } sur certaines architectures, obligeant le compilateur à l'assigner à un groupe de stencil distinct. De plus, si le champ vide est de type pointeur (**_ int*), cela change les exigences de traçage du ramasse-miettes pour ce type, imposant des cartes de pile séparées. Les développeurs optimisant pour la taille binaire doivent reconnaître que les formes de GC sont déterminées par la disposition mémoire complète—y compris les remplissages et les champs vides—plutôt que par les membres de données sémantiquement pertinents.