Maison > développement back-end > Golang > Allez aux génériques : une plongée en profondeur

Allez aux génériques : une plongée en profondeur

Mary-Kate Olsen
Libérer: 2025-01-01 01:51:09
original
967 Les gens l'ont consulté

Go Generics: A Deep Dive

1. Allez-y sans génériques

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.

2. Avantages des génériques

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.

3. Différences entre les génériques et l'interface{}

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.

3.1Interface{}Principe de mise en œuvre

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
}
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

_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")
}
Copier après la connexion
Copier après la connexion
Copier après la connexion

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.

3.2 Principe de mise en œuvre des génériques

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 :

  • Schéma de pochoir
  • Schéma des dictionnaires
  • Schéma de pochoir de forme GC

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
}
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

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")
}
Copier après la connexion
Copier après la connexion
Copier après la connexion

3.3 Différences

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.

4. Scénarios pour les génériques

4.1 Scénarios applicables

  • Lors de l'implémentation de structures de données générales : en utilisant des génériques, vous pouvez écrire du code une seule fois et le réutiliser sur différents types de données. Cela réduit la duplication de code et améliore la maintenabilité et l'extensibilité du code.
  • Lorsque vous travaillez sur des types de conteneurs natifs dans Go : si une fonction utilise des paramètres de types de conteneurs intégrés Go tels que des tranches, des cartes ou des canaux, et que le code de la fonction ne fait aucune hypothèse spécifique sur les types d'éléments dans les conteneurs , l'utilisation de génériques peut complètement découpler les algorithmes de conteneur des types d'éléments dans les conteneurs. Avant que la syntaxe générique ne soit disponible, la réflexion était généralement utilisée pour l'implémentation, mais la réflexion rend le code moins lisible, ne peut pas effectuer de vérification de type statique et augmente considérablement la surcharge d'exécution du programme.
  • Lorsque la logique des méthodes implémentées pour différents types de données est la même : lorsque les méthodes de différents types de données ont la même logique de fonction et que la seule différence est le type de données des paramètres d'entrée, des génériques peuvent être utilisés pour réduire la redondance du code.

4.2 Scénarios non applicables

  • Ne remplacez pas les types d'interface par des paramètres de type : les interfaces prennent en charge un certain sens de la programmation générique. Si les opérations sur les variables de certains types appellent uniquement les méthodes de ce type, utilisez simplement le type d'interface directement sans utiliser de génériques. Par exemple, io.Reader utilise une interface pour lire différents types de données à partir de fichiers et de générateurs de nombres aléatoires. io.Reader est facile à lire du point de vue du code, très efficace et il n'y a presque aucune différence dans l'efficacité d'exécution des fonctions, il n'est donc pas nécessaire d'utiliser des paramètres de type.
  • Lorsque les détails d'implémentation des méthodes pour différents types de données sont différents : si l'implémentation de la méthode pour chaque type est différente, le type d'interface doit être utilisé à la place des génériques.
  • Dans les scénarios avec une forte dynamique d'exécution : par exemple, dans les scénarios où le jugement de type est effectué à l'aide de switch, l'utilisation directe de l'interface{} donnera de meilleurs résultats.

5. Pièges dans les génériques

5.1 néant Comparaison

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
}
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

5.2 Éléments sous-jacents invalides

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
}
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

5.3 Éléments de type Union invalides

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")
}
Copier après la connexion
Copier après la connexion
Copier après la connexion

5.4 Les types d'interface ne peuvent pas être intégrés de manière récursive

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{}
Copier après la connexion

6. Meilleures pratiques

Pour faire bon usage des génériques, les points suivants sont à noter lors de leur utilisation :

  1. Évitez de trop généraliser. Les génériques ne conviennent pas à tous les scénarios et il est nécessaire d’examiner attentivement les scénarios dans lesquels ils sont appropriés. La réflexion peut être utilisée le cas échéant : Go a une réflexion à l'exécution. Le mécanisme de réflexion soutient un certain sens de programmation générique. Si certaines opérations doivent supporter les scénarios suivants, une réflexion peut être envisagée : (1) Fonctionnant sur des types sans méthodes, où le type d'interface n'est pas applicable. (2) Lorsque la logique de fonctionnement de chaque type est différente, les génériques ne sont pas applicables. Un exemple est l’implémentation du package encoding/json. Puisqu'il n'est pas souhaité que chaque type à encoder implémente la méthode MarshalJson, le type d'interface ne peut pas être utilisé. Et comme la logique de codage des différents types est différente, les génériques ne doivent pas être utilisés.
  2. Utilisez clairement *T, []T et map[T1]T2 au lieu de laisser T représenter des types de pointeurs, des tranches ou des cartes. Différent du fait que les paramètres de type en C sont des espaces réservés et seront remplacés par des types réels, le type du paramètre de type T dans Go est le paramètre de type lui-même. Par conséquent, le représenter sous forme de pointeur, de tranche, de carte et d'autres types de données entraînera de nombreuses situations inattendues lors de l'utilisation, comme indiqué ci-dessous :
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){}
Copier après la connexion

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 
}
Copier après la connexion

Résumé

Globalement, les avantages des génériques peuvent être résumés sous trois aspects :

  1. Les types sont déterminés pendant la période de compilation, garantissant ainsi la sécurité des types. Ce qui est mis, c'est ce qui est retiré.
  2. La lisibilité est améliorée. Le type de données réel est explicitement connu dès l'étape de codage.
  3. Les génériques fusionnent le code de traitement pour le même type, améliorant le taux de réutilisation du code et augmentant la flexibilité générale du programme. Toutefois, les génériques ne sont pas une nécessité pour les types de données généraux. Il est encore nécessaire de bien réfléchir à l'opportunité d'utiliser des génériques en fonction de la situation réelle d'utilisation.

Leapcell : la plate-forme avancée pour l'hébergement Web Go, les tâches asynchrones et Redis

Go Generics: A Deep Dive

Enfin, permettez-moi de vous présenter Leapcell, la plateforme la plus adaptée au déploiement des services Go.

1. Prise en charge multilingue

  • Développez avec JavaScript, Python, Go ou Rust.

2. Déployez gratuitement un nombre illimité de projets

  • payez uniquement pour l'utilisation – pas de demande, pas de frais.

3. Rentabilité imbattable

  • Payez à l'utilisation sans frais d'inactivité.
  • Exemple : 25 $ prend en charge 6,94 millions de requêtes avec un temps de réponse moyen de 60 ms.

4. Expérience de développeur rationalisée

  • Interface utilisateur intuitive pour une configuration sans effort.
  • Pipelines CI/CD entièrement automatisés et intégration GitOps.
  • Mesures et journalisation en temps réel pour des informations exploitables.

5. Évolutivité sans effort et hautes performances

  • Mise à l'échelle automatique pour gérer facilement une concurrence élevée.
  • Zéro frais opérationnels : concentrez-vous uniquement sur la construction.

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!

source:dev.to
Déclaration de ce site Web
Le contenu de cet article est volontairement contribué par les internautes et les droits d'auteur appartiennent à l'auteur original. Ce site n'assume aucune responsabilité légale correspondante. Si vous trouvez un contenu suspecté de plagiat ou de contrefaçon, veuillez contacter admin@php.cn
Derniers articles par auteur
Tutoriels populaires
Plus>
Derniers téléchargements
Plus>
effets Web
Code source du site Web
Matériel du site Web
Modèle frontal