Leçon 7/11 9 min

Les génériques

Écrivez du code qui marche pour plusieurs types sans le dupliquer : paramètres de type, contraintes et types génériques en Go.

Le problème : écrire deux fois le même code

Vous voulez une fonction qui renvoie le plus grand de deux valeurs. Pour des int, facile. Mais pour des float64, il faut la réécrire à l'identique, juste avec un autre type :

func MaxInt(a, b int) int {
    if a > b { return a }
    return b
}

func MaxFloat(a, b float64) float64 {  // exactement le même code...
    if a > b { return a }
    return b
}

Avant Go 1.18, on n'avait que deux mauvaises options : dupliquer (une fonction par type), ou passer par interface{} et perdre toute la sécurité de typage (et payer des conversions). Les génériques règlent ça.

Les paramètres de type [T]

Un paramètre de type se déclare entre crochets, juste après le nom de la fonction. T est un type « à trous » que le compilateur remplit à l'appel :

func Max[T int | float64](a, b T) T {
    if a > b {
        return a
    }
    return b
}

func main() {
    fmt.Println(Max(3, 5))        // 5   → T vaut int
    fmt.Println(Max(2.5, 1.0))    // 2.5 → T vaut float64
}

[T int | float64] dit : « T peut être un int ou un float64 ». Une seule fonction, et le compilateur génère la bonne version pour chaque type d'appel, sans perdre le typage.

Une seule fonction générique Max[T] est spécialisée par le compilateur en versions concrètes pour int, float64 et string. func Max[T](a, b T) T un seul code générique Max(3, 5) → int Max(2.5, 1.0) → float64 Max("a", "b") → string spécialisé par le compilateur
Un seul code source, des versions typées générées à la compilation pour chaque type d'appel.

Depuis Go 1.21, max et min existent comme fonctions intégrées : pas besoin d'écrire Max en vrai. L'exemple sert à montrer le mécanisme, qui s'applique à tout ce que vous écrirez de générique.

Les contraintes : ce que T a le droit d'être

Le bout entre crochets s'appelle une contrainte : elle limite les types autorisés ET détermine les opérations permises sur T. Trois cas à connaître :

  • any : n'importe quel type (c'est l'alias de interface{}). Mais alors on ne peut presque rien faire sur T à part le stocker.
  • comparable : les types qu'on peut comparer avec == et != (utile pour les clés de map).
  • une contrainte sur mesure : une interface qui énumère des types.
Prédisez avant de lire

On veut écrire une fonction générique Max[T any](a, b T) T qui renvoie le plus grand des deux. Elle contient if a > b { return a }. Avant de dérouler : cette fonction compile-t-elle ? Le a > b est-il accepté quand T est any ? Que faut-il changer pour que ça marche ?

Voir la réponse

Non, elle ne compile pas. La contrainte any autorise n'importe quel type, y compris des types qui ne savent pas se comparer avec > (structs, maps, slices, bool...). Le compilateur refuse donc a > b, car l'opération n'est pas garantie pour tout T. La solution : contraindre T aux types ordonnés avec cmp.Ordered (Go 1.21) : func Max[T cmp.Ordered](a, b T) T. Cette contrainte regroupe les entiers, flottants et chaînes, soit tout ce qui supporte < <= >= >. Une contrainte est exactement ça : la promesse de ce que T sait faire ; elle débloque dans le corps de la fonction les opérations qu'elle garantit.

// ~int signifie "int et tout type dont le sous-jacent est int"
type Nombre interface {
    ~int | ~int64 | ~float64
}

func Somme[T Nombre](valeurs []T) T {
    var total T
    for _, v := range valeurs {
        total += v          // autorisé : la contrainte garantit le +
    }
    return total
}

Pour les types ordonnés, la bibliothèque standard fournit déjà cmp.Ordered (Go 1.21) : func Max[T cmp.Ordered](a, b T) T couvre d'un coup les nombres ET les string.

Les types génériques

Pas que les fonctions : un type aussi peut être paramétré. L'exemple classique, une pile (stack) qui marche pour n'importe quel type :

type Pile[T any] struct {
    elements []T
}

func (p *Pile[T]) Empiler(v T) {
    p.elements = append(p.elements, v)
}

func (p *Pile[T]) Depiler() (T, bool) {
    var zero T
    if len(p.elements) == 0 {
        return zero, false
    }
    dernier := p.elements[len(p.elements)-1]
    p.elements = p.elements[:len(p.elements)-1]
    return dernier, true
}

func main() {
    var p Pile[string]   // une pile de strings, typée
    p.Empiler("a")
    p.Empiler("b")
    v, ok := p.Depiler()
    fmt.Println(v, ok)   // b true
}

La même Pile sert pour Pile[int], Pile[Compte], etc. : un seul code, typé et vérifié à la compilation pour chaque usage.

Quand (et quand ne pas) les utiliser

Les génériques brillent pour les conteneurs (piles, listes, caches) et les algorithmes qui traitent plein de types de la même façon (Map, Filter, Somme). Mais ils ne remplacent pas les interfaces.

Génériques ou interface ? Si tu as besoin d'un comportement commun (des types qui savent faire Aire()), c'est une interface. Si tu fais la même opération sur des types différents en gardant leur type exact (une pile de T), c'est un générique. Dans le doute, commence simple : n'introduis un générique que quand la duplication devient réelle.

À toi : complète cette fonction générique Contient qui dit si une valeur est dans un slice, puis exécute :

package main

import "fmt"

// Contient renvoie true si v est présent dans s.
func Contient[T comparable](s []T, v T) bool {
    for _, e := range s {
        if e == v {     // == est permis grâce à comparable
            return true
        }
    }
    return false
}

func main() {
    fmt.Println(Contient([]int{1, 2, 3}, 2))         // true
    fmt.Println(Contient([]string{"a", "b"}, "z"))   // false
}

À remarquer. La contrainte comparable est ce qui autorise le == dans la fonction. Avec any à la place, le code ne compilerait pas : Go saurait stocker v mais pas la comparer. La contrainte ne limite pas seulement les types, elle débloque les opérations.

The problem: writing the same code twice

You want a function that returns the larger of two values. For int, easy. But for float64, you have to rewrite it identically, just with another type:

func MaxInt(a, b int) int {
    if a > b { return a }
    return b
}

func MaxFloat(a, b float64) float64 {  // the exact same code...
    if a > b { return a }
    return b
}

Before Go 1.18, you only had two bad options: duplicate (one function per type), or go through interface{} and lose all type safety (and pay for conversions). Generics fix this.

Type parameters [T]

A type parameter is declared in brackets, right after the function name. T is a "fill-in-the-blank" type the compiler fills at the call site:

func Max[T int | float64](a, b T) T {
    if a > b {
        return a
    }
    return b
}

func main() {
    fmt.Println(Max(3, 5))        // 5   → T is int
    fmt.Println(Max(2.5, 1.0))    // 2.5 → T is float64
}

[T int | float64] says: "T can be an int or a float64". A single function, and the compiler generates the right version for each call type — without losing typing.

A single generic function Max[T] is specialized by the compiler into concrete versions for int, float64 and string. func Max[T](a, b T) T one generic source Max(3, 5) → int Max(2.5, 1.0) → float64 Max("a", "b") → string specialized by the compiler
One source, typed versions generated at compile time for each call type.

Since Go 1.21, max and min exist as built-in functions: you don't actually need to write Max. The example just shows the mechanism, which applies to anything generic you write.

Constraints: what T is allowed to be

The bit in brackets is called a constraint: it limits the allowed types AND determines which operations are permitted on T. Three cases to know:

  • any: any type at all (it's the alias for interface{}). But then you can barely do anything with T beyond storing it.
  • comparable: types you can compare with == and != (useful for map keys).
  • a custom constraint: an interface that lists types.
Predict before reading

We want to write a generic function Max[T any](a, b T) T that returns the larger of two values. It contains if a > b { return a }. Before scrolling: does this function compile? Is a > b accepted when T is any? What needs to change for it to work?

See the answer

No, it does not compile. The any constraint allows any type, including types that cannot be compared with > (structs, maps, slices, bool...). The compiler therefore refuses a > b, because the operation is not guaranteed for every T. The fix: constrain T to ordered types using cmp.Ordered (Go 1.21): func Max[T cmp.Ordered](a, b T) T. That constraint covers integers, floats and strings, i.e. everything that supports < <= >= >. A constraint is exactly that: a promise of what T can do; it unlocks inside the function body the operations the constraint guarantees.

// ~int means "int and any type whose underlying type is int"
type Number interface {
    ~int | ~int64 | ~float64
}

func Sum[T Number](values []T) T {
    var total T
    for _, v := range values {
        total += v          // allowed: the constraint guarantees +
    }
    return total
}

For ordered types, the standard library already provides cmp.Ordered (Go 1.21): func Max[T cmp.Ordered](a, b T) T covers numbers AND strings in one go.

Generic types

Not just functions: a type can be parameterized too. The classic example, a stack that works for any type:

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

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

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

func main() {
    var s Stack[string]   // a stack of strings, typed
    s.Push("a")
    s.Push("b")
    v, ok := s.Pop()
    fmt.Println(v, ok)    // b true
}

The same Stack serves Stack[int], Stack[Account], etc.: one source, typed and checked at compile time for each use.

When (and when not) to use them

Generics shine for containers (stacks, lists, caches) and algorithms that handle many types the same way (Map, Filter, Sum). But they don't replace interfaces.

Generics or interface? If you need shared behavior (types that can do Area()), that's an interface. If you do the same operation on different types while keeping their exact type (a stack of T), that's a generic. When in doubt, start simple: only introduce a generic when duplication becomes real.

Your turn: complete this generic Contains function that says whether a value is in a slice, then run it:

package main

import "fmt"

// Contains reports whether v is in s.
func Contains[T comparable](s []T, v T) bool {
    for _, e := range s {
        if e == v {     // == is allowed thanks to comparable
            return true
        }
    }
    return false
}

func main() {
    fmt.Println(Contains([]int{1, 2, 3}, 2))         // true
    fmt.Println(Contains([]string{"a", "b"}, "z"))   // false
}

Notice. The comparable constraint is what allows == inside the function. With any instead, the code wouldn't compile: Go would know how to store v but not compare it. A constraint doesn't only limit types, it unlocks operations.

🎯 Pratique

S'entraîner (clique pour ouvrir) :

Prompt IA
Avec l'IA

Copiez ce prompt dans Claude ou ChatGPT :

Écris en Go une fonction générique Map[T, U any](s []T, f func(T) U) []U qui transforme un slice. Montre-la utilisée pour doubler des entiers puis pour récupérer la longueur de chaînes. Explique la contrainte choisie.
💬 Ré-explique sans regarder
Ré-explique sans regarder

L'IA te rend une fonction Somme[T Nombre]. Explique avec tes mots : à quoi sert la contrainte Nombre, et pourquoi on ne peut pas juste écrire [T any] ici ?

Une bonne explication dit : la contrainte Nombre limite T aux types numériques (~int | ~float64…) ET, surtout, elle autorise l'opération + sur T. Avec [T any], T pourrait être n'importe quoi (un bool, un struct) : le compilateur refuserait total += v car l'addition n'a pas de sens pour tous les types. La contrainte ne fait pas que filtrer les types, elle débloque ce qu'on a le droit de faire avec.
⚖️ Juge le code de l'IA
Accepter ou rejeter le code de l'IA

L'IA propose cette fonction générique pour additionner un slice. L'accepter ou la rejeter, et pourquoi ?

func Somme[T any](valeurs []T) T {
    var total T
    for _, v := range valeurs {
        total += v
    }
    return total
}
À rejeter : ça ne compile pas. La contrainte any autorise n'importe quel type, donc Go interdit total += v : l'addition n'a pas de sens pour un bool ou un struct. Il faut une contrainte qui garantit le +, par exemple [T ~int | ~float64] (ou un type Nombre dédié). Règle : la contrainte doit autoriser toutes les opérations utilisées dans le corps.
🧠 Rappel libre
Rappel libre

Sans remonter : quelle contrainte faut-il pour pouvoir comparer deux T avec == dans une fonction générique ?

Il faut la contrainte comparable : func F[T comparable](…). Elle autorise == et != sur T (et c'est aussi ce qu'exigent les clés de map). Avec any, le compilateur refuserait e == v, car certains types (slices, maps, fonctions) ne sont pas comparables.
Où déclare-t-on un paramètre de type dans une fonction générique ?
À quoi sert une contrainte comme [T int | float64] ?
Tu veux la même opération sur plein de types en gardant leur type exact (ex. une pile de T). Génériques ou interface ?
Prochaine étape

Vos types ont du comportement (méthodes, interfaces) et votre code marche pour plusieurs types (génériques). On passe au superpouvoir de Go : faire tourner du code en parallèle avec les goroutines et les channels.

Leçon 8 : Goroutines et channels →

Une erreur dans cette leçon, un passage flou, une question ? Écrivez-moi : chaque retour améliore ce cours.

Besoin d'un développeur pour votre projet ?

Réponse sous 24h · Sans engagement