Génériques en Go : type parameter par l'exemple
1. Vue d’ensemble
À partir de Go 1.18, vous pouvez écrire des fonctions et des types de données avec des type parameter. Grâce à cela, vous n’avez pas besoin de dupliquer la même logique pour int, float64, string, et vous n’avez pas non plus besoin de faire une conversion de type à partir de interface{}.
Cet article part d’un problème initial pour arriver aux fonctions génériques, aux constraint et à la notation ~. Viennent ensuite les types génériques comme Stack[T] et Cache[K, V], comment écrire des tests, ainsi que les erreurs courantes. Après la lecture, vous saurez quand utiliser les génériques et quand une simple interface suffit.
Tous les exemples utilisent Go 1.22 ou supérieur. Le package cmp existe depuis Go 1.21. Votre go.mod minimal :
module example.com/shop
go 1.22
Pas de bibliothèque externe. Tout se trouve dans la standard library.
2. Le problème avant les génériques
Supposons que vous ayez besoin de calculer le nombre total d’articles (int) et le montant total (float64). Avant Go 1.18, vous écriviez généralement deux fonctions identiques :
func SumInts(xs []int) int {
var total int
for _, x := range xs {
total += x
}
return total
}
func SumFloats(xs []float64) float64 {
var total float64
for _, x := range xs {
total += x
}
return total
}
L’alternative était d’accepter []interface{} puis d’utiliser un type switch. Cela perd la sécurité des types. Les erreurs ne s’affichent qu’à l’exécution, et vous devez faire une conversion de type partout où vous appelez la fonction.
Les génériques résolvent précisément ce problème : écrire la logique une seule fois, et le compilateur vérifie toujours les types pour vous.
3. Première fonction générique
3.1. Syntaxe des type parameter
Les type parameter sont déclarés entre crochets, immédiatement après le nom de la fonction :
package main
import "fmt"
type Number interface {
~int | ~int64 | ~float64
}
func Sum[T Number](xs []T) T {
var total T
for _, x := range xs {
total += x
}
return total
}
func main() {
quantities := []int{2, 1, 5}
prices := []float64{120.5, 99.9, 15}
fmt.Println(Sum(quantities))
fmt.Println(Sum(prices))
}
Explication partie par partie :
[T Number]déclare le type parameterT.Tdoit satisfaire la constraintNumber.Numberest une interface contenant un type set :int,int64,float64et les types dérivés de ceux-ci (le sens de~se trouve à la section 4.3).var total Tcrée la valeur zéro deT, c’est-à-dire0ou0.0.- L’opérateur
+=est valide car tous les types du type set supportent l’addition.
Résultat :
8
235.4
3.2. Inférence de type
Avez-vous remarqué que nous appelons Sum(quantities) plutôt que Sum[int](quantities) ? Le compilateur déduit T automatiquement à partir de l’argument. C’est l’inférence de type.
Vous devez seulement le spécifier explicitement quand le compilateur n’a pas assez d’informations pour déduire, par exemple quand le type parameter n’apparaît que dans le type de retour :
func Zero[T any]() T {
var z T
return z
}
func main() {
fmt.Println(Zero[string]() == "") // true
}
3.3. Plusieurs type parameter : Map et Filter
Voici les deux fonctions utilitaires les plus courantes. Nous les appliquons à une liste de Order :
package main
import "fmt"
type Order struct {
ID string
Customer string
Total float64
}
func Map[T, U any](xs []T, f func(T) U) []U {
out := make([]U, 0, len(xs))
for _, x := range xs {
out = append(out, f(x))
}
return out
}
func Filter[T any](xs []T, keep func(T) bool) []T {
var out []T
for _, x := range xs {
if keep(x) {
out = append(out, x)
}
}
return out
}
func main() {
orders := []Order{
{"A01", "Lan", 250_000},
{"A02", "Minh", 1_200_000},
{"A03", "Lan", 890_000},
}
big := Filter(orders, func(o Order) bool { return o.Total >= 500_000 })
ids := Map(big, func(o Order) string { return o.ID })
fmt.Println(ids)
}
Map[T, U any]possède deux type parameter, donc le type d’entrée et de sortie peuvent être différents.anyest un alias deinterface{}. Il permet tous les types, mais vous ne pouvez appeler aucun opérateur sur sa valeur en dehors de l’assignation et du passage en argument.- Le compilateur déduit
T = OrderetU = stringà partir de la fonctionfunc(o Order) string.
Résultat :
[A02 A03]
4. Constraint : limiter ce que T peut faire
Une constraint détermine ce que vous pouvez faire avec une valeur de type T à l’intérieur de la fonction.
4.1. comparable
comparable est une constraint prédéfinie. Elle permet d’utiliser == et !=, et c’est aussi une condition pour que T soit la clé d’une map :
func Unique[T comparable](xs []T) []T {
seen := make(map[T]struct{}, len(xs))
var out []T
for _, x := range xs {
if _, ok := seen[x]; !ok {
seen[x] = struct{}{}
out = append(out, x)
}
}
return out
}
// Unique([]string{"Lan", "Minh", "Lan"}) => [Lan Minh]
Le struct Order ci-dessus est également comparable, car tous ses champs sont comparables. Un struct contenant une slice, une map ou une fonction n’est pas comparable, et le compilateur vous le signalera.
4.2. cmp.Ordered
Quand vous avez besoin de comparer plus grand ou plus petit, utilisez cmp.Ordered de la standard library :
import "cmp"
func MaxBy[T any, K cmp.Ordered](xs []T, key func(T) K) (T, bool) {
var best T
if len(xs) == 0 {
return best, false
}
best = xs[0]
for _, x := range xs[1:] {
if key(x) > key(best) {
best = x
}
}
return best, true
}
// top, _ := MaxBy(orders, func(o Order) float64 { return o.Total })
// top.ID => "A02"
K cmp.Orderedpermet d’utiliser>sur la valeur clé.- La fonction retourne
(T, bool)pour gérer la slice vide sans panique.
Attention : Pour les simples slices numériques, vous n’avez pas besoin de l’écrire vous-même. Go 1.21 a déjà les fonctions built-in
max/min, et le packageslicesdispose deslices.Max,slices.Contains,slices.Sort. Vérifiezslicesetmapsavant d’écrire vos propres utilitaires génériques.
4.3. Notation ~ (tilde)
Dans la pratique, vous définissez souvent vos propres types pour la monnaie :
type VND int64
func main() {
payments := []VND{150_000, 350_000}
fmt.Println(Sum(payments)) // 500000
}
Cette ligne compile car il y a ~int64 dans Number. La notation ~int64 signifie « tous les types ayant int64 comme underlying type ». Si vous écrivez int64 sans ~, alors VND ne satisfait pas la constraint, et le compilateur vous le dira :
VND does not satisfy Number (possibly missing ~ for int64 in Number)
Règle simple : quand vous écrivez une constraint pour des types fondamentaux, vous devriez presque toujours utiliser ~.
4.4. Constraint combinée avec des méthodes
Une constraint peut aussi exiger des méthodes, comme une interface habituelle :
type Identifiable interface {
comparable
Key() string
}
Un type qui veut satisfaire Identifiable doit être comparable ET avoir une méthode Key() string.
5. Types génériques
5.1. Stack[T]
Les type parameter ne s’utilisent pas seulement pour les fonctions, mais aussi pour les struct :
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 := len(s.items) - 1
v := s.items[last]
s.items = s.items[:last]
return v, true
}
func (s *Stack[T]) Len() int { return len(s.items) }
Utilisation :
var history Stack[Order]
history.Push(Order{ID: "A01"})
history.Push(Order{ID: "A02"})
o, _ := history.Pop()
fmt.Println(o.ID, history.Len()) // A02 1
- Le receiver est écrit
*Stack[T], il doit inclure[T]. - Quand vous déclarez une variable, vous devez spécifier le type :
Stack[Order]. Il n’y a pas d’inférence à ce stade. - La valeur zéro de
Stack[Order]est utilisable immédiatement, carappendfonctionne bien sur une slice nil.
5.2. Cache[K, V] thread-safe
Un exemple plus pratique : un cache en mémoire thread-safe pour partager entre Customer et Order.
package cache
import "sync"
type Cache[K comparable, V any] struct {
mu sync.RWMutex
data map[K]V
}
func New[K comparable, V any]() *Cache[K, V] {
return &Cache[K, V]{data: make(map[K]V)}
}
func (c *Cache[K, V]) Put(key K, value V) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
func (c *Cache[K, V]) Get(key K) (V, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.data[key]
return v, ok
}
type Customer struct {
ID int
Name string
}
customers := cache.New[int, Customer]()
customers.Put(1, Customer{ID: 1, Name: "Lan"})
orders := cache.New[string, Order]()
orders.Put("A01", Order{ID: "A01", Total: 250_000})
c, ok := customers.Get(1)
fmt.Println(c.Name, ok) // Lan true
Une même implémentation sert deux types de données. customers.Get retourne exactement Customer, pas besoin de type assertion. Si vous aviez accidentellement mis un Order dans customers, le code ne compilerait pas.
6. Écrire des tests pour du code générique
Go utilise le package testing standard. Le test table-driven fonctionne normalement avec des fonctions génériques. Chaque tableau de test correspond à un type spécifique :
package main
import "testing"
func TestSum(t *testing.T) {
ints := []struct {
name string
in []int
want int
}{
{"empty", nil, 0},
{"positive", []int{2, 1, 5}, 8},
{"mixed", []int{10, -3}, 7},
}
for _, tc := range ints {
t.Run(tc.name, func(t *testing.T) {
if got := Sum(tc.in); got != tc.want {
t.Errorf("Sum(%v) = %d, want %d", tc.in, got, tc.want)
}
})
}
if got := Sum([]VND{150_000, 350_000}); got != 500_000 {
t.Errorf("Sum VND = %d, want 500000", got)
}
}
func TestStackPopEmpty(t *testing.T) {
var s Stack[string]
v, ok := s.Pop()
if ok || v != "" {
t.Fatalf("Pop() on empty = (%q, %v), want (\"\", false)", v, ok)
}
}
Lancer les tests :
go test ./... -v
=== RUN TestSum
=== RUN TestSum/empty
=== RUN TestSum/positive
=== RUN TestSum/mixed
--- PASS: TestSum (0.00s)
=== RUN TestStackPopEmpty
--- PASS: TestStackPopEmpty (0.00s)
PASS
Testez au moins un type dérivé comme VND. C’est le moyen le plus rapide de détecter une constraint manquant ~.
7. Erreurs courantes et quand ne pas l’utiliser
Les méthodes ne peuvent pas avoir leurs propres type parameter. Ceci ne compile pas :
// erreur : methods cannot have type parameters
func (s *Stack[T]) Map[U any](f func(T) U) *Stack[U] { ... }
Écrivez-la comme une fonction top-level : func MapStack[T, U any](s *Stack[T], f func(T) U) *Stack[U].
Vous ne pouvez pas faire un type switch directement sur T. Pour brancher selon le type, vous devez passer par any : switch v := any(x).(type) { ... }. Si vous le faites souvent, les génériques ne sont probablement pas le bon outil.
Une constraint avec un type set ne peut pas être utilisée comme type de variable. var n Number sera une erreur. Une interface contenant | ou ~ ne peut s’utiliser qu’en position de constraint.
comparable avec interface peut paniquer. Si K est un type interface et que la valeur dynamique est une slice, la comparaison ou l’utilisation comme clé map va paniquer à l’exécution.
Note : Les génériques ne sont pas automatiquement plus rapides qu’une interface. Le compilateur Go utilise une technique de « GC shape stenciling » avec un dictionnaire. Différents types pointeur peuvent partager un seul morceau de code, et les appels de méthode via constraint ont toujours un surcoût indirect. Si les performances importent, mesurez avec un benchmark plutôt que de deviner.
Quand NE PAS utiliser les génériques :
- Quand la logique dépend du comportement (méthodes), pas du type. Par exemple, un
PaymentServicequi accepte unNotifieravec une méthodeSend. Une interface est généralement plus concise et lisible. - Quand seulement un type spécifique utilisera la fonction. N’ajoutez pas de génériques « au cas où » ultérieurement.
- Quand
slices,maps,cmpont déjà une fonction dont vous avez besoin.
Une règle facile à retenir : si vous écrivez le même bout de code pour plusieurs types, et la seule différence est le nom du type, c’est le moment d’utiliser les génériques.
8. Conclusion
Cet article a couvert les points clés des génériques en Go :
- Déclarer les type parameter
[T C]et laisser le compilateur déduire le type. - Utiliser les constraint
any,comparable,cmp.Ordered, et définir vos propres constraint avec~pour supporter les types dérivés commeVND. - Construire des types génériques
Stack[T]etCache[K, V]thread-safe. - Écrire des tests table-driven pour des fonctions génériques.
- Identifier les limitations : pas de type parameter dans les méthodes, les constraint ne sont pas des types de variables, et les génériques ne remplacent pas les interface.
Commencez par l’endroit où le code est vraiment dupliqué dans votre projet. Écrivez les génériques avec parcimonie, préférez la standard library, et testez toujours avec au moins deux types différents.