Avant l'introduction des génériques, il existait plusieurs approches pour implémenter des fonctions génériques prenant en charge différents types de données :
Approche 1 : Implémenter une fonction pour chaque type de données
Cette approche conduit à un code extrêmement redondant et à des coûts de maintenance élevés. Toute modification nécessite d'effectuer la même opération sur toutes les fonctions. De plus, comme le langage Go ne prend pas en charge la surcharge de fonctions portant le même nom, il est également peu pratique d'exposer ces fonctions pour les appels de modules externes.
Approche 2 : Utiliser le type de données avec la plus grande plage
Pour éviter la redondance du code, une autre méthode consiste à utiliser le type de données avec la plage la plus grande, c'est-à-dire l'approche 2. Un exemple typique est math.Max, qui renvoie le plus grand de deux nombres. Pour pouvoir comparer des données de différents types de données, math.Max utilise float64, le type de données avec la plus grande plage parmi les types numériques dans Go, comme paramètres d'entrée et de sortie, évitant ainsi toute perte de précision. Bien que cela résolve dans une certaine mesure le problème de redondance du code, tout type de données doit d'abord être converti en type float64. Par exemple, lorsque l'on compare int avec int, la conversion de type est toujours requise, ce qui non seulement dégrade les performances mais semble également contre nature.
Approche 3 : Utiliser le type interface{}
L'utilisation du type interface{} résout efficacement les problèmes ci-dessus. Cependant, le type interface{} introduit une certaine surcharge d'exécution car il nécessite des assertions de type ou des jugements de type au moment de l'exécution, ce qui peut entraîner une certaine dégradation des performances. De plus, lors de l'utilisation du type interface{}, le compilateur ne peut pas effectuer de vérification de type statique, donc certaines erreurs de type ne peuvent être découvertes qu'au moment de l'exécution.
Go 1.18 a introduit la prise en charge des génériques, ce qui constitue un changement important depuis l'open source du langage Go.
Les génériques sont une fonctionnalité des langages de programmation. Il permet aux programmeurs d'utiliser des types génériques au lieu de types réels dans la programmation. Ensuite, par passage explicite ou déduction automatique lors des appels réels, les types génériques sont remplacés, atteignant ainsi l'objectif de réutilisation du code. Lors de l'utilisation de génériques, le type de données sur lequel opérer est spécifié en tant que paramètre. Ces types de paramètres sont appelés respectivement classes génériques, interfaces génériques et méthodes génériques dans les classes, interfaces et méthodes.
Les principaux avantages des génériques sont l’amélioration de la réutilisabilité du code et de la sécurité des types. Par rapport aux paramètres formels traditionnels, les génériques rendent l'écriture de code universel plus concise et flexible, offrant la possibilité de gérer différents types de données et améliorant encore l'expressivité et la réutilisabilité du langage Go. Dans le même temps, puisque les types spécifiques de génériques sont déterminés au moment de la compilation, une vérification de type peut être fournie, évitant ainsi les erreurs de conversion de type.
Dans le langage Go, les interfaces{} et les génériques sont des outils permettant de gérer plusieurs types de données. Pour discuter de leurs différences, examinons d'abord les principes d'implémentation de l'interface{} et des génériques.
interface{} est une interface vide sans méthodes dans le type d'interface. Étant donné que tous les types implémentent l'interface{}, elle peut être utilisée pour créer des fonctions, des méthodes ou des structures de données pouvant accepter n'importe quel type. La structure sous-jacente de interface{} au moment de l'exécution est représentée par eface, dont la structure est illustrée ci-dessous, contenant principalement deux champs, _type et data.
type eface struct { _type *_type data unsafe.Pointer } type type struct { Size uintptr PtrBytes uintptr // number of (prefix) bytes in the type that can contain pointers Hash uint32 // hash of type; avoids computation in hash tables TFlag TFlag // extra type information flags Align_ uint8 // alignment of variable with this type FieldAlign_ uint8 // alignment of struct field with this type Kind_ uint8 // enumeration for C // function for comparing objects of this type // (ptr to object A, ptr to object B) -> ==? Equal func(unsafe.Pointer, unsafe.Pointer) bool // GCData stores the GC type data for the garbage collector. // If the KindGCProg bit is set in kind, GCData is a GC program. // Otherwise it is a ptrmask bitmap. See mbitmap.go for details. GCData *byte Str NameOff // string form PtrToThis TypeOff // type for pointer to this type, may be zero }
_type est un pointeur vers la structure _type, qui contient des informations telles que la taille, le type, la fonction de hachage et la représentation sous forme de chaîne de la valeur réelle. data est un pointeur vers les données réelles. Si la taille des données réelles est inférieure ou égale à la taille d'un pointeur, les données seront directement stockées dans le champ de données ; sinon, le champ de données stockera un pointeur vers les données réelles.
Lorsqu'un objet d'un type spécifique est affecté à une variable de type interface{}, le langage Go effectue implicitement l'opération de boxe d'eface, en définissant le champ _type sur le type de la valeur et le champ de données sur les données de la valeur. . Par exemple, lorsque l'instruction var i interface{} = 123 est exécutée, Go créera une structure eface, où le champ _type représente le type int et le champ de données représente la valeur 123.
Lors de la récupération de la valeur stockée à partir de l'interface {}, un processus de déballage se produit, c'est-à-dire une assertion de type ou un jugement de type. Ce processus nécessite de spécifier explicitement le type attendu. Si le type de la valeur stockée dans interface{} correspond au type attendu, l'assertion de type réussira et la valeur pourra être récupérée. Sinon, l'assertion de type échouera et une gestion supplémentaire est requise pour cette situation.
var i interface{} = "hello" s, ok := i.(string) if ok { fmt.Println(s) // Output "hello" } else { fmt.Println("not a string") }
On peut voir que l'interface{} prend en charge les opérations sur plusieurs types de données via des opérations de boxing et de unboxing au moment de l'exécution.
L'équipe principale de Go a été très prudente lors de l'évaluation des schémas de mise en œuvre des génériques Go. Au total, trois schémas de mise en œuvre ont été soumis :
Le schéma Stenciling est également le schéma d'implémentation adopté par des langages tels que C et Rust pour implémenter des génériques. Son principe de mise en œuvre est que pendant la période de compilation, en fonction des paramètres de type spécifiques lors de l'appel de la fonction générique ou des éléments de type dans les contraintes, une implémentation distincte de la fonction générique est générée pour chaque argument de type afin d'assurer la sécurité du type et des performances optimales. . Cependant, cette méthode ralentira le compilateur. Parce que lorsque de nombreux types de données sont appelés, la fonction générique doit générer des fonctions indépendantes pour chaque type de données, ce qui peut entraîner des fichiers compilés très volumineux. Dans le même temps, en raison de problèmes tels que les échecs de cache du processeur et la prédiction de branchement d'instructions, le code généré peut ne pas s'exécuter efficacement.
Le schéma Dictionnaires ne génère qu'une seule logique de fonction pour la fonction générique mais ajoute un paramètre dict comme premier paramètre de la fonction. Le paramètre dict stocke les informations liées au type des arguments de type lorsque la fonction générique est appelée et transmet les informations du dictionnaire à l'aide du registre AX (AMD) lors de l'appel de la fonction. L'avantage de ce schéma est qu'il réduit la surcharge de la phase de compilation et n'augmente pas la taille du fichier binaire. Cependant, il augmente la surcharge d'exécution, ne peut pas effectuer d'optimisation des fonctions au stade de la compilation et présente des problèmes tels que la récursivité du dictionnaire.
type eface struct { _type *_type data unsafe.Pointer } type type struct { Size uintptr PtrBytes uintptr // number of (prefix) bytes in the type that can contain pointers Hash uint32 // hash of type; avoids computation in hash tables TFlag TFlag // extra type information flags Align_ uint8 // alignment of variable with this type FieldAlign_ uint8 // alignment of struct field with this type Kind_ uint8 // enumeration for C // function for comparing objects of this type // (ptr to object A, ptr to object B) -> ==? Equal func(unsafe.Pointer, unsafe.Pointer) bool // GCData stores the GC type data for the garbage collector. // If the KindGCProg bit is set in kind, GCData is a GC program. // Otherwise it is a ptrmask bitmap. See mbitmap.go for details. GCData *byte Str NameOff // string form PtrToThis TypeOff // type for pointer to this type, may be zero }
Go a finalement intégré les deux schémas ci-dessus et a proposé le schéma GC Shape Stenciling pour une implémentation générique. Il génère un code de fonction en unités de forme GC d'un type. Les types avec la même forme GC réutilisent le même code (la forme GC d'un type fait référence à sa représentation dans l'allocateur de mémoire/garbage collector Go). Tous les types de pointeurs réutilisent le type *uint8. Pour les types avec la même forme GC, un code de fonction instancié partagé est utilisé. Ce schéma ajoute également automatiquement un paramètre dict à chaque code de fonction instancié pour distinguer différents types avec la même forme GC.
var i interface{} = "hello" s, ok := i.(string) if ok { fmt.Println(s) // Output "hello" } else { fmt.Println("not a string") }
D'après les principes d'implémentation sous-jacents de l'interface{} et des génériques, nous pouvons constater que la principale différence entre eux est que l'interface{} prend en charge la gestion de différents types de données pendant l'exécution, tandis que les génériques prennent en charge la gestion statique de différents types de données au stade de la compilation. Il existe principalement les différences suivantes dans l'utilisation pratique :
(1) Différence de performances : les opérations de boxing et de unboxing effectuées lorsque différents types de données sont attribués ou récupérés à partir de l'interface {} sont coûteuses et introduisent une surcharge supplémentaire. En revanche, les génériques ne nécessitent pas d'opérations de boxing et de unboxing, et le code généré par les génériques est optimisé pour des types spécifiques, évitant ainsi une surcharge de performances d'exécution.
(2) Sécurité des types : lors de l'utilisation du type interface{}, le compilateur ne peut pas effectuer de vérification de type statique et ne peut effectuer des assertions de type qu'au moment de l'exécution. Par conséquent, certaines erreurs de type peuvent n’être découvertes qu’au moment de l’exécution. En revanche, le code générique de Go est généré au moment de la compilation, de sorte que le code générique peut obtenir des informations de type au moment de la compilation, garantissant ainsi la sécurité du type.
Dans le langage Go, les paramètres de type ne peuvent pas être directement comparés à nil car les paramètres de type sont vérifiés au moment de la compilation, tandis que nil est une valeur spéciale au moment de l'exécution. Étant donné que le type sous-jacent du paramètre type est inconnu au moment de la compilation, le compilateur ne peut pas déterminer si le type sous-jacent du paramètre type prend en charge la comparaison avec nil. Par conséquent, pour maintenir la sécurité des types et éviter d'éventuelles erreurs d'exécution, le langage Go ne permet pas de comparaison directe entre les paramètres de type et nil.
type eface struct { _type *_type data unsafe.Pointer } type type struct { Size uintptr PtrBytes uintptr // number of (prefix) bytes in the type that can contain pointers Hash uint32 // hash of type; avoids computation in hash tables TFlag TFlag // extra type information flags Align_ uint8 // alignment of variable with this type FieldAlign_ uint8 // alignment of struct field with this type Kind_ uint8 // enumeration for C // function for comparing objects of this type // (ptr to object A, ptr to object B) -> ==? Equal func(unsafe.Pointer, unsafe.Pointer) bool // GCData stores the GC type data for the garbage collector. // If the KindGCProg bit is set in kind, GCData is a GC program. // Otherwise it is a ptrmask bitmap. See mbitmap.go for details. GCData *byte Str NameOff // string form PtrToThis TypeOff // type for pointer to this type, may be zero }
Le type T de l'élément sous-jacent doit être un type de base et ne peut pas être un type d'interface.
type eface struct { _type *_type data unsafe.Pointer } type type struct { Size uintptr PtrBytes uintptr // number of (prefix) bytes in the type that can contain pointers Hash uint32 // hash of type; avoids computation in hash tables TFlag TFlag // extra type information flags Align_ uint8 // alignment of variable with this type FieldAlign_ uint8 // alignment of struct field with this type Kind_ uint8 // enumeration for C // function for comparing objects of this type // (ptr to object A, ptr to object B) -> ==? Equal func(unsafe.Pointer, unsafe.Pointer) bool // GCData stores the GC type data for the garbage collector. // If the KindGCProg bit is set in kind, GCData is a GC program. // Otherwise it is a ptrmask bitmap. See mbitmap.go for details. GCData *byte Str NameOff // string form PtrToThis TypeOff // type for pointer to this type, may be zero }
Les éléments de type Union ne peuvent pas être des paramètres de type et les éléments non-interface doivent être disjoints par paires. S'il y a plus d'un élément, il ne peut pas contenir de type d'interface avec des méthodes non vides, ni être comparable ou intégrer un comparable.
var i interface{} = "hello" s, ok := i.(string) if ok { fmt.Println(s) // Output "hello" } else { fmt.Println("not a string") }
type Op interface{ int|float } func Add[T Op](m, n T) T { return m + n } // After generation => const dict = map[type] typeInfo{ int : intInfo{ newFunc, lessFucn, //...... }, float : floatInfo } func Add(dict[T], m, n T) T{}
Pour faire bon usage des génériques, les points suivants sont à noter lors de leur utilisation :
type V interface{ int|float|*int|*float } func F[T V](m, n T) {} // 1. Generate templates for regular types int/float func F[go.shape.int_0](m, n int){} func F[go.shape.float_0](m, n int){} // 2. Pointer types reuse the same template func F[go.shape.*uint8_0](m, n int){} // 3. Add dictionary passing during the call const dict = map[type] typeInfo{ int : intInfo{}, float : floatInfo{} } func F[go.shape.int_0](dict[int],m, n int){}
Le code ci-dessus signalera une erreur : opération invalide : les pointeurs de ptr (variable de type T contrainte par *int | *uint) doivent avoir des types de base identiques. La raison de cette erreur est que T est un paramètre de type et que le paramètre de type n'est pas un pointeur et ne prend pas en charge l'opération de déréférencement. Cela peut être résolu en modifiant la définition comme suit :
// Wrong example func ZeroValue0[T any](v T) bool { return v == nil } // Correct example 1 func Zero1[T any]() T { return *new(T) } // Correct example 2 func Zero2[T any]() T { var t T return t } // Correct example 3 func Zero3[T any]() (t T) { return }
Globalement, les avantages des génériques peuvent être résumés sous trois aspects :
Enfin, permettez-moi de vous présenter Leapcell, la plateforme la plus adaptée au déploiement des services Go.
Explorez-en davantage dans la documentation !
Twitter de Leapcell : https://x.com/LeapcellHQ
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!