Generics en Go : quand les utiliser, quand les éviter

J'avais trois repositories qui se ressemblaient trait pour trait. UserRepository, ProductRepository, OrderRepository : même structure, même FindByID, même List, même logique de pagination. La seule différence, c'était le type retourné. Trois fois le même code. Quand Go 1.18 est sorti avec les generics, j'ai voulu tout fusionner. Ce qui a suivi m'a appris quand les generics sont une bonne idée — et quand ils empirent le problème.

Ce que les generics font réellement

Les generics permettent d'écrire du code paramétré par des types. Là où avant on devait soit dupliquer le code pour chaque type, soit utiliser interface{} avec des assertions de type au runtime, on peut maintenant écrire une seule implémentation qui fonctionne pour plusieurs types — avec vérification à la compilation.

// Avant Go 1.18 : duplication ou interface{}
func ContainsInt(slice []int, val int) bool {
    for _, v := range slice { if v == val { return true } }
    return false
}
func ContainsString(slice []string, val string) bool {
    for _, v := range slice { if v == val { return true } }
    return false
}

// Avec les generics : une seule implémentation
func Contains[T comparable](slice []T, val T) bool {
    for _, v := range slice {
        if v == val {
            return true
        }
    }
    return false
}

T comparable est une contrainte : T peut être n'importe quel type qui supporte ==. Le compilateur vérifie que les types passés satisfont la contrainte — pas de surprise au runtime.

Les patterns où les generics brillent

1. Fonctions utilitaires sur les slices et maps

C'est le cas d'usage le plus propre. Des opérations génériques sur des collections, sans logique métier :

func Filter[T any](slice []T, predicate func(T) bool) []T {
    result := make([]T, 0, len(slice))
    for _, v := range slice {
        if predicate(v) {
            result = append(result, v)
        }
    }
    return result
}

func Map[T, U any](slice []T, transform func(T) U) []U {
    result := make([]U, len(slice))
    for i, v := range slice {
        result[i] = transform(v)
    }
    return result
}

func Keys[K comparable, V any](m map[K]V) []K {
    keys := make([]K, 0, len(m))
    for k := range m {
        keys = append(keys, k)
    }
    return keys
}

À l'utilisation, c'est lisible et idiomatique :

activeUsers := Filter(users, func(u User) bool { return u.Active })
emails := Map(users, func(u User) string { return u.Email })
categoryIDs := Keys(categoryMap)

2. Structures de données typées

Un cache thread-safe, une queue, un résultat optionnel — des structures qu'on réutilise partout mais qu'on veut typées :

type Cache[K comparable, V any] struct {
    mu    sync.RWMutex
    items map[K]cacheEntry[V]
    ttl   time.Duration
}

type cacheEntry[V any] struct {
    value   V
    expires time.Time
}

func NewCache[K comparable, V any](ttl time.Duration) *Cache[K, V] {
    return &Cache[K, V]{
        items: make(map[K]cacheEntry[V]),
        ttl:   ttl,
    }
}

func (c *Cache[K, V]) Set(key K, value V) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.items[key] = cacheEntry[V]{value: value, expires: time.Now().Add(c.ttl)}
}

func (c *Cache[K, V]) Get(key K) (V, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    entry, ok := c.items[key]
    if !ok || time.Now().After(entry.expires) {
        var zero V
        return zero, false
    }
    return entry.value, true
}

Sans generics, ce cache retourne interface{} et force une assertion de type à chaque appel. Avec les generics, le type est connu à la compilation :

userCache := NewCache[int, User](5 * time.Minute)
userCache.Set(42, User{Name: "Alice"})
user, ok := userCache.Get(42) // user est un User, pas un interface{}

3. Result et Option types

Un pattern courant pour éviter les valeurs nulles ou les bool de retour implicites :

type Optional[T any] struct {
    value   T
    present bool
}

func Some[T any](v T) Optional[T] { return Optional[T]{value: v, present: true} }
func None[T any]() Optional[T]    { return Optional[T]{} }

func (o Optional[T]) Unwrap() (T, bool) { return o.value, o.present }
func (o Optional[T]) OrDefault(def T) T {
    if o.present { return o.value }
    return def
}

Quand les generics empirent tout

Le piège du repository générique

Revenons à mes trois repositories. L'idée paraissait bonne :

type Repository[T any] struct {
    db    *sql.DB
    table string
    scan  func(*sql.Rows) (T, error)
}

func (r *Repository[T]) FindByID(ctx context.Context, id int) (T, error) {
    rows, err := r.db.QueryContext(ctx, "SELECT * FROM "+r.table+" WHERE id = $1", id)
    // ...
    return r.scan(rows)
}

func (r *Repository[T]) List(ctx context.Context) ([]T, error) {
    // ...
}

En pratique, ça s'est cassé immédiatement. UserRepository a besoin d'un FindByEmail. ProductRepository a besoin d'un FindByCategory. OrderRepository a besoin d'un FindByCustomerWithItems qui fait une jointure. La partie commune — FindByID et List — représente 15% du code total. Le reste est spécifique. Le générique facttorise les 15%, complique les 85%.

// Ce qu'on se retrouve à écrire quand même
type UserRepository struct {
    base *Repository[User] // la partie générique
    db   *sql.DB           // pour les requêtes spécifiques
}

// La complexité a juste changé d'endroit

Les contraintes trop complexes

Quand les contraintes génériques commencent à ressembler à ça, c'est un signal :

// ❌ Trop complexe — utiliser une interface à la place
type Processable[T Entity, R Result, E interface{ error }] interface {
    Process(context.Context, T) (R, E)
    Validate(T) E
    Rollback(context.Context, T) E
}

// ✅ Une interface simple fait le même travail
type Processor interface {
    Process(ctx context.Context, input any) (any, error)
}

Si votre contrainte générique a besoin d'elle-même pour être définie, ou si elle implique plus d'un type paramètre avec des relations entre eux, les interfaces ordinaires sont presque toujours plus lisibles.

Quand les méthodes diffèrent selon le type

Les generics ne permettent pas de spécialiser le comportement selon le type au runtime. Si la logique est différente pour int et string, les generics n'aident pas — il faut des interfaces ou deux fonctions distinctes.

La règle pratique pour décider

Situation Solution
Même algorithme, types différents, logique identique ✅ Generics
Structure de données réutilisable (cache, queue, optional) ✅ Generics
Comportement variable selon le type ✅ Interfaces
Factoriser du code métier qui diffère à 85% ❌ Éviter les generics
Contrainte avec plus de 2 type params ❌ Probablement une interface
Besoin de reflection ou de type switch ❌ Generics ne peuvent pas aider

La question clé : est-ce que la logique est identique pour tous les types, ou seulement la structure ? Si c'est la logique : generics. Si c'est le comportement : interfaces. Si c'est les deux : composez les deux.

Conclusion

Les generics Go sont une addition mesurée à un langage qui valorisait déjà la simplicité. Ils ne remplacent pas les interfaces — ils comblent un angle mort spécifique : les algorithmes et structures de données qui fonctionnent identiquement pour n'importe quel type.

Ce qui m'a pris du temps à intégrer : les generics sont un outil pour éliminer la duplication de code structurel, pas pour factoriser de la logique métier. Dès que la logique commence à diverger selon le type, on quitte le territoire des generics et on entre dans celui des interfaces — qui, avec les functional options et une bonne DI, couvrent la grande majorité des cas d'architecture Go réelle.

Commentaires (0)