Contactez-nous

Introduction aux génériques

Découvrez les génériques en Go : types paramétrés, fonctions génériques, avantages, limitations, et cas d'usage pour un code Go plus flexible et réutilisable.

Introduction aux génériques en Go : La généricité enfin accessible

Avec la sortie de Go 1.18, les génériques (generics), également appelés types paramétrés ou programmation paramétrique, ont été officiellement introduits dans le langage Go, après des années de débats et d'attente au sein de la communauté Go. Les génériques apportent une nouvelle dimension à la programmation Go, en permettant d'écrire du code plus générique, plus réutilisable, et plus flexible, tout en conservant les avantages du typage statique et de la performance de Go.

Imaginez les génériques comme des "templates" de code, qui peuvent être instanciés (spécialisés) avec différents types concrets lors de leur utilisation. Au lieu d'écrire du code spécifique pour chaque type de données, vous pouvez écrire du code générique qui fonctionne avec n'importe quel type (ou un ensemble de types compatibles) qui satisfait certaines contraintes ou propriétés. Les génériques permettent de factoriser le code commun, de réduire la duplication, d'améliorer la lisibilité et la maintenabilité, et d'écrire des bibliothèques et des outils Go plus puissants et plus réutilisables.

Ce chapitre vous introduit au monde des génériques en Go. Nous allons explorer en détail les concepts fondamentaux des génériques (types paramétrés, fonctions génériques, contraintes de type, instanciation de types génériques, type inference), les avantages et les cas d'utilisation des génériques en Go, les limitations et les compromis de l'utilisation des génériques, et les bonnes pratiques pour intégrer les génériques de manière judicieuse et efficace dans vos projets Go. Que vous soyez novice ou développeur expérimenté, ce guide essentiel vous fournira les clés pour comprendre et maîtriser les génériques en Go et exploiter leur potentiel pour écrire du code Go plus générique, plus réutilisable, et plus puissant.

Types paramétrés (Type Parameters) : Définir des types génériques

Les types paramétrés (type parameters) sont la base des génériques en Go. Ils permettent de définir des types génériques, c'est-à-dire des types (structs, interfaces, types dérivés) qui sont paramétrés par un ou plusieurs types (les type parameters). Les types paramétrés agissent comme des "variables de type" qui seront remplacées par des types concrets lors de l'instanciation du type générique.

Syntaxe de déclaration d'un type paramétré (type générique) :

Pour déclarer un type paramétré (type générique) en Go, vous ajoutez une liste de type parameters entre crochets [] après le nom du type, dans la déclaration du type (type ...). Chaque type parameter est défini par un nom (identificateur) et une contrainte de type (type constraint) optionnelle.

type NomTypeGénérique[TypeParam1 ContrainteType1, TypeParam2 ContrainteType2, ...] struct {
    // ... définition du struct générique utilisant les type parameters TypeParam1, TypeParam2, ...
}

type NomInterfaceGénérique[TypeParam1 ContrainteType1, TypeParam2 ContrainteType2, ...] interface {
    // ... définition de l'interface générique utilisant les type parameters TypeParam1, TypeParam2, ...
}

type NomTypeDérivéGénérique[TypeParam1 ContrainteType1, TypeParam2 ContrainteType2, ...] TypeDeBase // Type dérivé générique

  • type NomTypeGénérique[TypeParam1 ContrainteType1, ...] ... : Déclare un nouveau type paramétré (type générique) nommé NomTypeGénérique. Le nom du type générique suit les conventions de nommage Go (PascalCase).
  • [TypeParam1 ContrainteType1, TypeParam2 ContrainteType2, ...] : La liste des type parameters, entre crochets []. Chaque type parameter est défini par :
    • TypeParam1, TypeParam2, ... : Les noms des type parameters (identificateurs, souvent des noms courts comme T, K, V, E, U, etc., par convention). Les noms des type parameters sont utilisés pour référencer les types génériques à l'intérieur de la définition du type générique.
    • ContrainteType1, ContrainteType2, ... (optionnel) : Les contraintes de type (type constraints) pour chaque type parameter. Les contraintes de type spécifient les types concrets autorisés pour remplacer le type parameter lors de l'instanciation du type générique. Les contraintes de type sont des interfaces Go (ou des unions de types). Si aucune contrainte de type n'est spécifiée, le type parameter est implicitement contraint à l'interface vide interface{} (aucune contrainte, n'importe quel type concret est autorisé).
  • struct { ... }, interface { ... }, TypeDeBase : La définition du type générique (struct, interface, type dérivé), utilisant les type parameters TypeParam1, TypeParam2, ... dans sa définition (par exemple, comme types de champs de struct, comme types de paramètres ou de retour de méthodes d'interface, comme type sous-jacent d'un type dérivé).

Exemples de déclarations de types paramétrés (types génériques) :

package main

// Type paramétré 'Pile' (Pile générique)
type Pile[T any] struct { // Type parameter 'T' avec la contrainte 'any' (n'importe quel type)
    elements []T
}

// Type paramétré 'Conteneur' avec deux type parameters 'T' et 'U'
type Conteneur[T comparable, U interface{ String() string }] struct { // Type parameters 'T' et 'U' avec des contraintes
    Cle   T
    Valeur U
}

// Interface générique 'Collection'
type Collection[T any] interface {
    Ajouter(element T)
    Retirer() T
    EstVide() bool
}

func main() {
    // ... utilisation des types paramétrés (instanciation) ...
}

Ces exemples illustrent différentes déclarations de types paramétrés (types génériques) en Go : un struct générique Pile[T] avec un seul type parameter T contraint à any (n'importe quel type), un struct générique Conteneur[T, U] avec deux type parameters T (contraint à comparable) et U (contraint à une interface avec une méthode String() string), et une interface générique Collection[T] avec un type parameter T contraint à any.

Fonctions génériques : Ecrire du code réutilisable pour différents types

Les fonctions génériques, en complément des types paramétrés, permettent d'écrire des fonctions qui fonctionnent avec différents types de données, en utilisant des type parameters dans la signature de la fonction. Les fonctions génériques permettent de factoriser la logique algorithmique et de la réutiliser pour différents types de données, améliorant ainsi la réutilisabilité du code et réduisant la duplication.

Syntaxe de déclaration d'une fonction générique :

Pour déclarer une fonction générique en Go, vous ajoutez une liste de type parameters entre crochets [] après le nom de la fonction, avant la liste des paramètres de la fonction.

func NomFonctionGénérique[TypeParam1 ContrainteType1, TypeParam2 ContrainteType2, ...](paramètre1 type1, paramètre2 type2, ...) (typeRetour1, typeRetour2, ...) {
    // ... corps de la fonction générique utilisant les type parameters TypeParam1, TypeParam2, ...
}

  • func NomFonctionGénérique[TypeParam1 ContrainteType1, ...] ... : Déclare une nouvelle fonction générique nommée NomFonctionGénérique. Le nom de la fonction générique suit les conventions de nommage Go (PascalCase ou camelCase).
  • [TypeParam1 ContrainteType1, TypeParam2 ContrainteType2, ...] : La liste des type parameters, entre crochets [], placée après le nom de la fonction et avant la liste des paramètres. La syntaxe des type parameters est la même que pour les types paramétrés (nom et contrainte de type optionnelle). Les type parameters définis dans la liste peuvent être utilisés dans la signature de la fonction (types des paramètres, types de retour) et dans le corps de la fonction.
  • (paramètre1 type1, paramètre2 type2, ...) : La liste des paramètres de la fonction, suivant la syntaxe habituelle des fonctions Go. Les types des paramètres peuvent inclure les type parameters définis dans la liste des type parameters de la fonction (par exemple, paramètre1 TypeParam1).
  • (typeRetour1, typeRetour2, ...) : La liste des types de retour de la fonction, également suivant la syntaxe habituelle des fonctions Go. Les types de retour peuvent également inclure les type parameters.
  • { ... } : Le corps de la fonction générique, contenant le code Go qui sera exécuté lorsque la fonction est appelée. Le corps de la fonction peut utiliser les type parameters comme des types de données génériques, en respectant les contraintes de type spécifiées (si des contraintes sont définies).

Exemples de déclarations de fonctions génériques :

package main

import "fmt"

// Fonction générique 'Max' (Maximum générique) avec un type parameter 'T' contraint à 'constraints.Ordered'
import "golang.org/x/exp/constraints" // Import constraints pour les contraintes de type prédéfinies

func Max[T constraints.Ordered](a T, b T) T {
    if a > b {
        return a
    }
    return b
}

// Fonction générique 'InverserSlice' (Inverser un slice générique) avec un type parameter 'T' sans contrainte (any)
func InverserSlice[T any](s []T) []T {
    for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
        s[i], s[j] = s[j], s[i]
    }
    return s
}

func main() {
    maxInt := Max[int](5, 10)         // Instanciation de Max avec le type 'int'
    maxFloat := Max[float64](3.14, 2.71) // Instanciation de Max avec le type 'float64'
    fmt.Println("Max int:", maxInt)
    fmt.Println("Max float:", maxFloat)

    sliceInt := []int{1, 2, 3, 4, 5}
    sliceInverseInt := InverserSlice[int](sliceInt) // Instanciation de InverserSlice avec le type 'int'
    fmt.Println("Slice inversé (int):", sliceInverseInt)

    sliceString := []string{"a", "b", "c"}
    sliceInverseString := InverserSlice[string](sliceString) // Instanciation de InverserSlice avec le type 'string'
    fmt.Println("Slice inversé (string):", sliceInverseString)
}

Ces exemples illustrent différentes déclarations de fonctions génériques en Go : une fonction générique Max[T] avec un type parameter T contraint à constraints.Ordered (qui permet d'utiliser les opérateurs de comparaison >, <, >=, <= sur le type T), et une fonction générique InverserSlice[T] avec un type parameter T sans contrainte (any, n'importe quel type).

Contraintes de type (Type Constraints) : Définir les propriétés des types génériques

Les contraintes de type (type constraints), utilisées dans la déclaration des types paramétrés et des fonctions génériques, permettent de restreindre les types concrets autorisés pour remplacer un type parameter, en spécifiant un ensemble de propriétés ou de comportements que les types concrets doivent satisfaire. Les contraintes de type sont définies sous forme d'interfaces Go (ou d'unions de types).

Syntaxe des contraintes de type : Interfaces Go

Les contraintes de type sont définies en utilisant la syntaxe des interfaces Go. Une contrainte de type est une interface qui spécifie un ensemble de méthodes (ou aucun méthode, interface vide interface{} ou contrainte any) que les types concrets doivent implémenter pour satisfaire la contrainte.

Contraintes de type prédéfinies du package constraints (Go 1.18+) :

Le package golang.org/x/exp/constraints (package expérimental, potentiellement intégré à la bibliothèque standard Go dans les versions futures) propose un ensemble de contraintes de type prédéfinies couramment utilisées, facilitant la déclaration de contraintes de type pour les types génériques :

  • constraints.Ordered : Contrainte de type pour les types ordonnés, c'est-à-dire les types qui supportent les opérateurs de comparaison ordinale (<, <=, >, >=). Inclut les types numériques (entiers, flottants) et le type string. Utile pour les fonctions génériques qui nécessitent de comparer ou de trier des valeurs (comme la fonction Max[T constraints.Ordered](a, b T) T de l'exemple précédent).
  • constraints.Integer : Contrainte de type pour les types entiers (signés et non signés).
  • constraints.Float : Contrainte de type pour les types flottants (float32, float64).
  • constraints.Complex : Contrainte de type pour les types complexes (complex64, complex128).
  • constraints.Signed : Contrainte de type pour les types entiers signés.
  • constraints.Unsigned : Contrainte de type pour les types entiers non signés.

Utilisation des contraintes de type : Vérification à la compilation et code générique type-safe

Les contraintes de type jouent un rôle essentiel dans la sécurité du typage et la vérification à la compilation du code générique en Go. Lorsque vous déclarez un type paramétré ou une fonction générique avec des contraintes de type, le compilateur Go vérifie à la compilation que les types concrets utilisés lors de l'instanciation du type générique ou de l'appel de la fonction générique satisfont bien les contraintes de type spécifiées. Si un type concret ne satisfait pas la contrainte de type, le compilateur Go signale une erreur de compilation, vous empêchant d'utiliser le code générique avec des types incompatibles.

Les contraintes de type permettent d'écrire du code générique qui est type-safe et vérifié à la compilation, combinant ainsi la flexibilité et la réutilisabilité des génériques avec la sécurité et la robustesse du typage statique de Go. Les contraintes de type permettent de définir des interfaces de types pour le code générique, en spécifiant les opérations et les méthodes qui sont autorisées sur les types génériques, et en garantissant que le code générique fonctionne correctement et en toute sécurité avec tous les types concrets qui satisfont ces contraintes.

Instanciation de types génériques et appel de fonctions génériques : Utilisation du code générique

Une fois que vous avez défini des types paramétrés (types génériques) et des fonctions génériques, vous pouvez les utiliser dans votre code Go en les instanciant (pour les types génériques) ou en les appelant (pour les fonctions génériques) avec des types concrets spécifiques.

Instanciation de types paramétrés (types génériques) :

Pour instancier un type paramétré (type générique), vous spécifiez les types concrets à utiliser pour remplacer les type parameters lors de la déclaration d'une variable du type générique, en utilisant la syntaxe NomTypeGénérique[TypeConcret1, TypeConcret2, ...].

package main

// Type paramétré 'Pile' (Pile générique) - (définition du chapitre précédent)
type Pile[T any] struct {
    elements []T
}

func main() {
    // Instanciation du type générique 'Pile[T]' avec le type concret 'int' : Pile d'entiers
    var pileEntiers Pile[int] = Pile[int]{}
    pileEntiers.elements = append(pileEntiers.elements, 10)
    pileEntiers.elements = append(pileEntiers.elements, 20)

    // Instanciation du type générique 'Pile[T]' avec le type concret 'string' : Pile de strings
    var pileStrings Pile[string] = Pile[string]{}
    pileStrings.elements = append(pileStrings.elements, "Bonjour")
    pileStrings.elements = append(pileStrings.elements, "Go")

    // ... utilisation des instances de types génériques ...
    fmt.Println("Pile d'entiers :", pileEntiers)
    fmt.Println("Pile de strings :", pileStrings)
}

Dans cet exemple, Pile[int] et Pile[string] sont des instanciations du type paramétré Pile[T] avec les types concrets int et string respectivement. Vous pouvez créer des variables de ces types instanciés et les utiliser comme des types Go normaux, mais avec la flexibilité de travailler avec des piles d'entiers, des piles de strings, ou des piles d'autres types concrets, en utilisant le même code générique Pile[T].

Appel de fonctions génériques : Instanciation implicite (type inference) ou explicite

Pour appeler une fonction générique, vous pouvez spécifier explicitement les types concrets à utiliser pour remplacer les type parameters lors de l'appel de fonction, en utilisant la syntaxe NomFonctionGénérique[TypeConcret1, TypeConcret2, ...](arguments).

Cependant, dans de nombreux cas, Go peut inférer automatiquement les types concrets à utiliser (type inference) à partir des types des arguments passés à la fonction générique. Dans ces cas, vous pouvez omettre la spécification explicite des types concrets lors de l'appel de fonction, et Go inférera automatiquement les types appropriés.

Exemples d'appel de fonctions génériques (instanciation implicite et explicite) :

package main

import "fmt"

// Fonction générique 'Max' (Maximum générique) - (définition du chapitre précédent)
func Max[T constraints.Ordered](a T, b T) T { /* ... */ }

// Fonction générique 'InverserSlice' (Inverser un slice générique) - (définition du chapitre précédent)
func InverserSlice[T any](s []T) []T { /* ... */ }

func main() {
    // Appel de fonction générique 'Max' avec instanciation explicite du type 'int'
    maxIntExplicite := Max[int](5, 10)
    fmt.Println("Max int (explicite):", maxIntExplicite)

    // Appel de fonction générique 'Max' avec instanciation implicite du type 'int' (type inference)
    maxIntImplicite := Max(5, 10) // Go infère automatiquement le type 'int' à partir des arguments 5 et 10
    fmt.Println("Max int (implicite):", maxIntImplicite)

    // Appel de fonction générique 'InverserSlice' avec instanciation explicite du type 'string'
    sliceInverseStringExplicite := InverserSlice[string]([]string{"a", "b", "c"})
    fmt.Println("Slice inversé (string, explicite):", sliceInverseStringExplicite)

    // Appel de fonction générique 'InverserSlice' avec instanciation implicite du type 'string' (type inference)
    sliceInverseStringImplicite := InverserSlice([]string{"d", "e", "f"}) // Go infère automatiquement le type 'string' à partir de []string{"d", "e", "f"}
    fmt.Println("Slice inversé (string, implicite):", sliceInverseStringImplicite)
}

Dans cet exemple, les appels à Max[int](5, 10) et InverserSlice[string](...) illustrent l'instanciation explicite des fonctions génériques avec les types concrets int et string. Les appels Max(5, 10) et InverserSlice([]string{"d", "e", "f"}) illustrent l'instanciation implicite (type inference), où Go infère automatiquement les types concrets à partir des arguments passés aux fonctions génériques, simplifiant ainsi la syntaxe d'appel des fonctions génériques dans de nombreux cas.

Bonnes pratiques pour l'utilisation des génériques en Go

Pour utiliser les génériques de manière efficace et pertinente dans vos projets Go, et éviter les pièges potentiels, voici quelques bonnes pratiques à suivre :

  • Utiliser les génériques uniquement lorsque c'est réellement justifié et nécessaire : N'introduisez pas les génériques systématiquement ou inutilement dans votre code. Utilisez les génériques uniquement lorsque cela apporte une réelle valeur ajoutée en termes de réutilisabilité du code, de généricité, ou de performance, et uniquement lorsque les avantages des génériques l'emportent sur la complexité potentielle qu'ils introduisent. Dans de nombreux cas, le code Go typé statiquement et spécifique à un type de données concret (sans génériques) reste plus simple, plus lisible, plus facile à comprendre, et potentiellement plus performant (en évitant l'overhead de l'instanciation des génériques et de la compilation du code générique). Privilégiez la simplicité et la lisibilité du code Go autant que possible, et n'introduisez les génériques que lorsque cela est réellement justifié par les besoins de votre projet.
  • Définir des contraintes de type claires et précises (interfaces) : Lorsque vous utilisez des type parameters dans vos types génériques ou vos fonctions génériques, définissez des contraintes de type claires et précises (interfaces Go) pour restreindre les types concrets autorisés et pour spécifier les opérations que vous pouvez effectuer sur les valeurs de type générique à l'intérieur du code générique. Des contraintes de type bien définies améliorent la sécurité du typage, la vérification à la compilation, et la lisibilité du code générique, et permettent au compilateur Go d'effectuer des optimisations plus agressives. Utilisez les contraintes de type prédéfinies du package constraints (constraints.Ordered, constraints.Integer, constraints.Float, etc.) lorsque cela est possible et pertinent, pour bénéficier de contraintes de type courantes et bien établies.
  • Nommer les type parameters de manière concise et significative : Choisissez des noms de type parameters concis (généralement des noms d'une seule lettre comme T, K, V, E, U, etc.) et significatifs, qui indiquent clairement le rôle ou la nature du type générique. Par exemple, utilisez T pour un type générique "Type", K pour un type générique "Key", V pour un type générique "Value", E pour un type générique "Element", etc. Des noms de type parameters clairs et significatifs facilitent la compréhension du code générique et de son intention.
  • Documenter clairement le code générique et les type parameters : Documentez clairement vos types paramétrés et vos fonctions génériques, en expliquant pourquoi les génériques sont utilisés, quels sont les type parameters et leurs contraintes de type, quels sont les types concrets autorisés pour l'instanciation, et quels sont les avantages et les limitations de l'approche générique. Une bonne documentation est essentielle pour faciliter la compréhension, l'utilisation, et la maintenance du code générique par les autres développeurs (et par vous-même dans le futur), en particulier en raison de la complexité potentielle introduite par les génériques.
  • Tester rigoureusement le code générique avec des tests unitaires complets : Testez rigoureusement votre code générique avec des tests unitaires complets et exhaustifs, en couvrant un large éventail de types concrets pour l'instanciation des types génériques et l'appel des fonctions génériques. Testez les cas nominaux (happy path) et les cas d'erreur, et assurez-vous que le code générique fonctionne correctement et de manière type-safe avec tous les types concrets autorisés par les contraintes de type. Les tests unitaires sont essentiels pour garantir la qualité et la robustesse du code générique, en particulier en raison de la flexibilité et du dynamisme potentiellement accru introduits par les génériques.
  • Benchmarkez et profilez le code générique pour évaluer la performance (si la performance est critique) : Si la performance est un aspect critique pour votre application Go qui utilise des génériques, benchmarkez et profilez attentivement votre code générique pour évaluer son impact sur la performance et identifier d'éventuels goulots d'étranglement liés à l'utilisation des génériques. Bien que les génériques en Go soient conçus pour être performants, ils peuvent introduire un certain overhead de performance par rapport au code Go statique et typé dans certains cas (en particulier pour les types value et les types complexes). Mesurez et comparez la performance du code générique avec des alternatives non génériques (code dupliqué, code basé sur l'interface vide interface{} et les assertions de type, code generation) pour choisir l'approche la plus performante et la plus adaptée à vos besoins spécifiques.

En appliquant ces bonnes pratiques, vous utiliserez les génériques de manière judicieuse et responsable dans vos projets Go, en tirant parti de leurs avantages pour la généricité, la réutilisabilité, et la flexibilité du code, tout en minimisant les risques et les compromis potentiels en termes de complexité, de lisibilité, de sécurité du typage, et de performance. Les génériques sont un outil puissant qui enrichit le langage Go, mais ils doivent être utilisés avec discernement et en respectant les principes de simplicité et de pragmatisme qui sont au coeur de la philosophie Go.