Le système de types de Go impose que chaque type concret possède un ensemble de méthodes fini et statiquement déterminable pour permettre un dispatch d'interface en O(1). Si une méthode sur un récepteur non générique pouvait déclarer ses propres paramètres de type—comme func (t *MyType) Process[T any](x T)—le type exhiberait théoriquement un ensemble de méthodes infini, instancié paresseusement pour chaque argument de type possible T.
Ce design anéantirait les garanties de disposition de la itab (table des interfaces), qui reposent sur des offsets fixes pour les pointeurs de méthode. En restreignant les paramètres de type à la définition du type elle-même (par exemple, type MyType[T any] struct{}), Go veille à ce que chaque instanciation distincte produise une table de métadonnées complète et finie au moment de la compilation. Cela préserve la prévisibilité de la taille binaire et maintient les caractéristiques de performance des appels d'interface via le dispatch statique.
Lors de l'architecture d'un pipeline de télémétrie à fort débit, notre équipe avait besoin d'un MetricCollector centralisé capable d'ingérer des types de données disparates—compteurs, histogrammes et jauges—tout en maintenant la sécurité des types à la compilation. Nous souhaitions initialement une API ressemblant à collector.Record[T Metric](value T), où MetricCollector restait un type concret pour éviter de forcer les utilisateurs à paramétrer le collecteur lui-même.
Le problème est apparu immédiatement : Go a rejeté le paramètre de type au niveau de la méthode, nous obligeant à choisir entre l'effacement de type (stockage de any et casting) ou le fractionnement du collecteur en plusieurs instances génériques. Nous avons évalué trois approches distinctes.
D'abord, nous avons envisagé d'élever MetricCollector à un type générique MetricCollector[T Metric]. Cela permettrait la méthode func (mc *MetricCollector[T]) Record(value T). Avantages : pleine sécurité des types et stockage sans allocation. Inconvénients : les utilisateurs nécessitaient des instances de collecteur séparées pour les compteurs et les jauges, éliminant la capacité d'agréger des métriques mixtes dans un seul registre sans encapsulage d'interface.
Deuxièmement, nous avons exploré la génération de code en utilisant go:generate pour créer des méthodes monomorphisées telles que RecordCounter, RecordGauge, etc., pour chaque type de métrique. Avantages : instance unique de collecteur avec des méthodes sûres pour les types. Inconvénients : complexité de compilation, contrôle de source gonflé et nécessité de régénérer le code chaque fois que de nouveaux types de métriques apparaissaient.
Troisièmement, nous avons pivoté vers une fonction générique au niveau du package func Record[T Metric](c *MetricCollector, value T). Cette approche découple le paramètre de type du récepteur. Avantages : conservation d'une instance unique de collecteur, préservation de la sécurité des types par la monomorphisation du compilateur de la fonction et évitement de la surcharge d'interface. Inconvénients : syntaxe légèrement moins idiomatique "orientée objet", nécessitant que les utilisateurs passent le collecteur comme argument explicite plutôt qu'en tant que récepteur de méthode.
Nous avons sélectionné la troisième solution car elle équilibré l'ergonomie de l'API avec les contraintes architecturales de Go. Le résultat a été un collecteur capable de gérer des types de métriques hétérogènes via une interface unifiée, avec tous les incompatibilités de types capturées à la compilation plutôt que lors des déploiements en production.
type Metric interface { Type() string } type MetricCollector struct { storage map[string][]any } // Invalid: func (mc *MetricCollector) Record[T Metric](value T) // Valid: Fonction générique avec un argument de collecteur explicite func Record[T Metric](mc *MetricCollector, value T) { key := value.Type() mc.storage[key] = append(mc.storage[key], value) }
Pourquoi Go permet-il des méthodes comme func (t *Tree[T]) Insert(x T) mais rejette func (t *Tree) Insert[T](x T) ?
Lorsque le récepteur lui-même est générique (Tree[T]), l'ensemble des méthodes est instancié concrètement pour chaque argument de type spécifique (par exemple, Tree[int] a une méthode Insert(x int)). L'ensemble des méthodes reste fini car il est lié à l'ensemble fini des instanciations présentes dans le programme. Pour un récepteur non générique, permettre Insert[T] impliquerait une famille de méthodes illimitée indexée par un univers de types infinis, nécessitant des dictionnaires de méthodes à l'exécution ou des tables de dispatch dynamique qui violent les garanties de liaison statique et d'appel rapide d'interface de Go.
Comment la satisfaction d'interface serait-elle rompue si les types concrets prenaient en charge des méthodes génériques ?
La satisfaction d'interface dans Go repose sur une vérification statique : le compilateur vérifie qu'un type implémente une interface en comparant les signatures de méthode. Si MyType pouvait implémenter Method[T](), alors satisfaire interface { Method[int]() } serait distinct de interface { Method[string]() }. Le compilateur devrait générer d'innombrables variations de vtable ou différer les vérifications de satisfaction à l'exécution, transformant les appels d'interface de simples recherches par offset de pointeur en résolutions dynamiques coûteuses, altérant fondamentalement le modèle de performance du langage.
Les paramètres de type peuvent-ils être simulés sur des types concrets à l'aide de champs de struct qui contiennent des fonctions génériques ?
Oui, mais avec des compromis sémantiques critiques. On peut définir type Processor struct { handle func[T any](T) }, mais cela stocke une instanciation concrète d'une fonction, pas une méthode paramétrée. Alternativement, on peut stocker une carte de reflect.Type vers des fonctions de traitement. Avantages : flexibilité à l'exécution. Inconvénients : perte de la sécurité des types à la compilation, frais de réflexion, et rupture de l'abstraction d'interface car la struct ne possède plus la méthode dans son ensemble des méthodes—juste un champ—empêchant le type de satisfaire les interfaces requérant cette opération.