Go Programming

Les génériques Go en action : créer des collections et des structures de données typées

Depuis la sortie de Go 1.18, le langage a connu son évolution la plus significative en matière de systèmes de types. L'introduction des génériques permet aux développeurs Go d'écrire du code qui est à la fois réutilisable et sûr, réduisant ainsi le besoin de code répétitif (boilerplate) ou d'assertions de type non sécurisées. Pour les développeurs intermédiaires et avancés, maîtriser les génériques n'est plus une option : c'est essentiel pour construire des applications robustes et évolutives.

Dans cet article, nous irons au-delà des fonctions simples pour plonger dans la création de collections personnalisées et typées. Nous explorerons comment exploiter les contraintes de type pour créer des structures de données qui fonctionnent de manière transparente avec différents types tout en maintenant les garanties de sécurité strictes pour lesquelles Go est connu.

Comprendre les contraintes de type

Avant de construire des structures complexes, il est crucial de comprendre les contraintes de type. Dans Go, une contrainte de type définit un ensemble de types qui peuvent être utilisés avec un type générique ou une fonction. Les contraintes peuvent être de simples interfaces ou des types composites complexes utilisant des unions (|).

Pour nos structures de données, nous avons souvent besoin de prendre en charge les types comparables (pour les cartes et les ensembles) ou les types ordonnés (pour le tri). Go fournit des contraintes intégrées comme comparable et ordered dans le package constraints, mais nous définissons souvent nos propres interfaces pour une flexibilité maximale.

package main

import (
    "fmt"
)

// Orderable définit les types qui prennent en charge les opérateurs de comparaison
type Orderable interface {
    ~int | ~float64 | ~string
}

Construire une pile générique (Stack)

Commençons par une structure de données fondamentale : une pile (Stack). Traditionnellement, une implémentation de pile nécessiterait une implémentation par type de données (par exemple, IntStack, StringStack). Avec les génériques, nous pouvons créer une seule implémentation qui gère n'importe quel type.

Voici une implémentation de pile typée et sécurisée utilisant les génériques Go :

package main

type Stack[T any] struct {
    items []T
}

func NewStack[T any]() *Stack[T] {
    return &Stack[T]{}
}

func (s *Stack[T]) Push(item T) {
    s.items = append(s.items, item)
}

func (s *Stack[T]) Pop() (T, bool) {
    if len(s.items) == 0 {
        var zero T
        return zero, false
    }
    index := len(s.items) - 1
    item := s.items[index]
    s.items = s.items[:index]
    return item, true
}

func (s *Stack[T]) Peek() (T, bool) {
    if len(s.items) == 0 {
        var zero T
        return zero, false
    }
    return s.items[len(s.items)-1], true
}

func (s *Stack[T]) IsEmpty() bool {
    return len(s.items) == 0
}

Cette implémentation est élégante car le paramètre de type T de la pile peut être n'importe quel type. Cependant, cela conduit à un piège potentiel en matière de performance lors de la manipulation de grands ensembles de données.

Optimisation avec des contraintes d'interface

Bien que T any soit flexible, le stockage de pointeurs ou de structures peut entraîner des allocations mémoire inutiles. Pour les collections comme les piles, les files d'attente ou les listes, il est souvent bénéfique de contraindre T aux types qui implémentent interface{} ou des comportements spécifiques pour autoriser la sémantique de valeur lorsque cela est approprié.

Considérons un scénario où nous voulons construire une file d'attente générique (Queue). Contrairement à une pile, une file d'attente doit gérer le retrait depuis le début, ce qui peut être inefficace avec les tranches (slices). Une approche par liste chaînée serait peut-être meilleure, mais restons sur une implémentation basée sur une tranche pour simplifier, en nous concentrant sur la sécurité des types.

type Queue[T comparable] struct {
    items []T
}

func NewQueue[T comparable]() *Queue[T] {
    return &Queue[T]{
        items: make([]T, 0),
    }
}

func (q *Queue[T]) Enqueue(item T) {
    q.items = append(q.items, item)
}

func (q *Queue[T]) Dequeue() (T, bool) {
    if len(q.items) == 0 {
        var zero T
        return zero, false
    }
    item := q.items[0]
    q.items = q.items[1:]
    return item, true
}

En utilisant comparable, nous limitons la file d'attente aux types qui peuvent être comparés, ce qui est utile si nous prévoyons d'ajouter plus tard des opérations comme la vérification des doublons.

Application pratique : recherche générique dans une collection

L'un des usages les plus puissants des génériques est la création de méthodes utilitaires génériques pour vos structures de données. Par exemple, l'ajout d'une fonction de recherche générique à notre Stack nécessite que le type soit comparable.

func (s *Stack[T]) Contains(item T) bool {
    for _, v := range s.items {
        if v == item {
            return true
        }
    }
    return false
}

Si nous essayons d'utiliser cette méthode avec une structure qui ne prend pas en charge les vérifications d'égalité (c'est-à-dire qui contient des tranches, des cartes ou des fonctions), le compilateur Go empêchera la compilation du code, garantissant ainsi la sécurité des types au moment de la construction.

Bonnes pratiques et conclusion

Lorsque vous travaillez avec les génériques Go, gardez ces bonnes pratiques à l'esprit :

  • Évitez le sur-ingénierie : N'utilisez pas les génériques pour tout. Utilisez-les lorsque vous avez du code répétitif qui diffère uniquement par le type.
  • Privilégiez les types valeurs : Dans la mesure du possible, utilisez des types valeurs dans les contraintes pour éviter la surcharge de l'indirection des pointeurs, sauf si vous avez spécifiquement besoin d'une sémantique de référence.
  • Nommage clair : Utilisez des paramètres de type à une seule lettre (T, E, K) à moins que le nom n'apporte une clarté significative.

Les génériques dans Go sont un outil puissant pour créer des collections typées et des structures de données personnalisées. En exploitant les contraintes de type, vous pouvez écrire du code flexible, efficace et maintenable qui évolue avec les besoins de votre application. À mesure que l'écosystème Go continue de mûrir, attendez-vous à voir émerger davantage de bibliothèques standardisées et d'idiomes autour de ces modèles.

Commencez à refactoriser votre code répétitif dès aujourd'hui et découvrez les gains de productivité que les génériques Go offrent.

Share: